mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-23 10:32:17 -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().should('have.length', 1);
|
||||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should show node name and version in settings', () => {
|
it('should show node name and version in settings', () => {
|
||||||
cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`);
|
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.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)');
|
||||||
ndv.actions.close();
|
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', () => {
|
it('should properly show node execution indicator', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Code');
|
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||||
workflowPage.actions.openNode('Code');
|
workflowPage.actions.openNode('Code');
|
||||||
|
@ -499,6 +528,7 @@ describe('NDV', () => {
|
||||||
ndv.getters.nodeExecuteButton().click();
|
ndv.getters.nodeExecuteButton().click();
|
||||||
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly show node execution indicator for multiple nodes', () => {
|
it('should properly show node execution indicator for multiple nodes', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Code');
|
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||||
workflowPage.actions.openNode('Code');
|
workflowPage.actions.openNode('Code');
|
||||||
|
@ -513,6 +543,7 @@ describe('NDV', () => {
|
||||||
workflowPage.actions.openNode('Code');
|
workflowPage.actions.openNode('Code');
|
||||||
ndv.getters.nodeRunErrorIndicator().should('exist');
|
ndv.getters.nodeRunErrorIndicator().should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should handle mismatched option attributes', () => {
|
it('Should handle mismatched option attributes', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
||||||
// Add some attributes in Create operation
|
// Add some attributes in Create operation
|
||||||
|
@ -521,6 +552,7 @@ describe('NDV', () => {
|
||||||
// Attributes should be empty after operation change
|
// Attributes should be empty after operation change
|
||||||
cy.getByTestId('parameter-item').contains('Currently no items exist').should('exist');
|
cy.getByTestId('parameter-item').contains('Currently no items exist').should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should keep RLC values after operation change', () => {
|
it('Should keep RLC values after operation change', () => {
|
||||||
const TEST_DOC_ID = '1111';
|
const TEST_DOC_ID = '1111';
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Google Sheets', { keepNdvOpen: true, action: 'Append row in sheet' });
|
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"
|
@update:selectedValue="selectedJsonPath = $event"
|
||||||
>
|
>
|
||||||
<template #renderNodeKey="{ node }">
|
<template #renderNodeKey="{ node }">
|
||||||
<span
|
<TextWithHighlights
|
||||||
|
:content="getContent(node.key)"
|
||||||
|
:search="search"
|
||||||
data-target="mappable"
|
data-target="mappable"
|
||||||
:data-value="getJsonParameterPath(node.path)"
|
:data-value="getJsonParameterPath(node.path)"
|
||||||
:data-name="node.key"
|
:data-name="node.key"
|
||||||
|
@ -43,13 +45,18 @@
|
||||||
[$style.mappable]: mappingEnabled,
|
[$style.mappable]: mappingEnabled,
|
||||||
[$style.dragged]: draggingPath === node.path,
|
[$style.dragged]: draggingPath === node.path,
|
||||||
}"
|
}"
|
||||||
v-html="highlightSearchTerm(node.key)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #renderNodeValue="{ node }">
|
<template #renderNodeValue="{ node }">
|
||||||
<span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
|
<TextWithHighlights
|
||||||
<span
|
v-if="isNaN(node.index)"
|
||||||
|
:content="getContent(node.content)"
|
||||||
|
:search="search"
|
||||||
|
/>
|
||||||
|
<TextWithHighlights
|
||||||
v-else
|
v-else
|
||||||
|
:content="getContent(node.content)"
|
||||||
|
:search="search"
|
||||||
data-target="mappable"
|
data-target="mappable"
|
||||||
:data-value="getJsonParameterPath(node.path)"
|
:data-value="getJsonParameterPath(node.path)"
|
||||||
:data-name="getListItemName(node.path)"
|
:data-name="getListItemName(node.path)"
|
||||||
|
@ -60,7 +67,6 @@
|
||||||
[$style.dragged]: draggingPath === node.path,
|
[$style.dragged]: draggingPath === node.path,
|
||||||
}"
|
}"
|
||||||
class="ph-no-capture"
|
class="ph-no-capture"
|
||||||
v-html="highlightSearchTerm(node.content)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</vue-json-pretty>
|
</vue-json-pretty>
|
||||||
|
@ -76,7 +82,6 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||||
import Draggable from '@/components/Draggable.vue';
|
import Draggable from '@/components/Draggable.vue';
|
||||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||||
import { isString } from '@/utils/typeGuards';
|
import { isString } from '@/utils/typeGuards';
|
||||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
|
||||||
import { shorten } from '@/utils/typesUtils';
|
import { shorten } from '@/utils/typesUtils';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
|
@ -86,6 +91,7 @@ import { getMappedExpression } from '@/utils/mappingUtils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { nonExistingJsonPath } from '@/constants';
|
import { nonExistingJsonPath } from '@/constants';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
import TextWithHighlights from './TextWithHighlights.vue';
|
||||||
|
|
||||||
const RunDataJsonActions = defineAsyncComponent(
|
const RunDataJsonActions = defineAsyncComponent(
|
||||||
async () => import('@/components/RunDataJsonActions.vue'),
|
async () => import('@/components/RunDataJsonActions.vue'),
|
||||||
|
@ -98,6 +104,7 @@ export default defineComponent({
|
||||||
Draggable,
|
Draggable,
|
||||||
RunDataJsonActions,
|
RunDataJsonActions,
|
||||||
MappingPill,
|
MappingPill,
|
||||||
|
TextWithHighlights,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
editMode: {
|
editMode: {
|
||||||
|
@ -202,9 +209,6 @@ export default defineComponent({
|
||||||
getListItemName(path: string): string {
|
getListItemName(path: string): string {
|
||||||
return path.replace(/^(\["?\d"?]\.?)/g, '');
|
return path.replace(/^(\["?\d"?]\.?)/g, '');
|
||||||
},
|
},
|
||||||
highlightSearchTerm(value: string): string {
|
|
||||||
return sanitizeHtml(highlightText(this.getContent(value), this.search));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { INodeUi, Schema } from '@/Interface';
|
import type { INodeUi, Schema } from '@/Interface';
|
||||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
|
||||||
import { checkExhaustive } from '@/utils/typeGuards';
|
import { checkExhaustive } from '@/utils/typeGuards';
|
||||||
import { shorten } from '@/utils/typesUtils';
|
import { shorten } from '@/utils/typesUtils';
|
||||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||||
|
import TextWithHighlights from './TextWithHighlights.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
|
@ -30,12 +30,8 @@ const isFlat = computed(
|
||||||
props.schema.value.every((v) => !Array.isArray(v.value)),
|
props.schema.value.every((v) => !Array.isArray(v.value)),
|
||||||
);
|
);
|
||||||
const key = computed((): string | undefined => {
|
const key = computed((): string | undefined => {
|
||||||
const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
|
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
|
||||||
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
|
|
||||||
});
|
});
|
||||||
const parentKey = computed((): string | undefined =>
|
|
||||||
sanitizeHtml(highlightText(props.parent.key, props.search)),
|
|
||||||
);
|
|
||||||
const schemaName = computed(() =>
|
const schemaName = computed(() =>
|
||||||
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
|
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
|
||||||
);
|
);
|
||||||
|
@ -99,8 +95,17 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||||
data-target="mappable"
|
data-target="mappable"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
||||||
<span v-if="isSchemaParentTypeArray" v-html="parentKey" />
|
<TextWithHighlights
|
||||||
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
|
v-if="isSchemaParentTypeArray"
|
||||||
|
:content="props.parent?.key"
|
||||||
|
:search="props.search"
|
||||||
|
/>
|
||||||
|
<TextWithHighlights
|
||||||
|
v-if="key"
|
||||||
|
:class="{ [$style.arrayIndex]: isSchemaParentTypeArray }"
|
||||||
|
:content="key"
|
||||||
|
:search="props.search"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="text" :class="$style.text">{{ text }}</span>
|
<span v-if="text" :class="$style.text">{{ text }}</span>
|
||||||
|
|
|
@ -52,7 +52,10 @@
|
||||||
[$style.draggingHeader]: isDragging,
|
[$style.draggingHeader]: isDragging,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span v-html="highlightSearchTerm(column || '')" />
|
<TextWithHighlights
|
||||||
|
:content="getValueToRender(column || '')"
|
||||||
|
:search="search"
|
||||||
|
/>
|
||||||
<div :class="$style.dragButton">
|
<div :class="$style.dragButton">
|
||||||
<font-awesome-icon icon="grip-vertical" />
|
<font-awesome-icon icon="grip-vertical" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -117,10 +120,11 @@
|
||||||
@mouseleave="onMouseLeaveCell"
|
@mouseleave="onMouseLeaveCell"
|
||||||
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
|
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
|
||||||
>
|
>
|
||||||
<span
|
<TextWithHighlights
|
||||||
v-if="isSimple(data)"
|
v-if="isSimple(data)"
|
||||||
|
:content="getValueToRender(data)"
|
||||||
|
:search="search"
|
||||||
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
|
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
|
||||||
v-html="highlightSearchTerm(data)"
|
|
||||||
/>
|
/>
|
||||||
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
|
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
|
||||||
<template #label="{ label, path }">
|
<template #label="{ label, path }">
|
||||||
|
@ -141,9 +145,10 @@
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<template #value="{ value }">
|
<template #value="{ value }">
|
||||||
<span
|
<TextWithHighlights
|
||||||
|
:content="getValueToRender(value)"
|
||||||
|
:search="search"
|
||||||
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
|
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
|
||||||
v-html="highlightSearchTerm(value)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n8n-tree>
|
</n8n-tree>
|
||||||
|
@ -162,7 +167,6 @@ import type { PropType } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import type { INodeUi, ITableData, NDVState } from '@/Interface';
|
import type { INodeUi, ITableData, NDVState } from '@/Interface';
|
||||||
import { shorten } from '@/utils/typesUtils';
|
import { shorten } from '@/utils/typesUtils';
|
||||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
|
||||||
import { getPairedItemId } from '@/utils/pairedItemUtils';
|
import { getPairedItemId } from '@/utils/pairedItemUtils';
|
||||||
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||||
import Draggable from './Draggable.vue';
|
import Draggable from './Draggable.vue';
|
||||||
|
@ -171,6 +175,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import MappingPill from './MappingPill.vue';
|
import MappingPill from './MappingPill.vue';
|
||||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
import TextWithHighlights from './TextWithHighlights.vue';
|
||||||
|
|
||||||
const MAX_COLUMNS_LIMIT = 40;
|
const MAX_COLUMNS_LIMIT = 40;
|
||||||
|
|
||||||
|
@ -178,7 +183,7 @@ type DraggableRef = InstanceType<typeof Draggable>;
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'run-data-table',
|
name: 'run-data-table',
|
||||||
components: { Draggable, MappingPill },
|
components: { Draggable, MappingPill, TextWithHighlights },
|
||||||
props: {
|
props: {
|
||||||
node: {
|
node: {
|
||||||
type: Object as PropType<INodeUi>,
|
type: Object as PropType<INodeUi>,
|
||||||
|
@ -392,9 +397,6 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
highlightSearchTerm(value: string): string {
|
|
||||||
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
|
|
||||||
},
|
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
this.draggedColumn = true;
|
this.draggedColumn = true;
|
||||||
this.ndvStore.resetMappingTelemetry();
|
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);
|
}, 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