feat(editor): Node IO filter (#7503)
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
@@ -13,12 +13,15 @@
|
||||
:mappingEnabled="isMappingEnabled"
|
||||
:distanceFromActive="currentNodeDepth"
|
||||
:isProductionExecutionPreview="isProductionExecutionPreview"
|
||||
:isPaneActive="isPaneActive"
|
||||
@activatePane="activatePane"
|
||||
paneType="input"
|
||||
@itemHover="$emit('itemHover', $event)"
|
||||
@linkRun="onLinkRun"
|
||||
@unlinkRun="onUnlinkRun"
|
||||
@runChange="onRunIndexChange"
|
||||
@tableMounted="$emit('tableMounted', $event)"
|
||||
@search="$emit('search', $event)"
|
||||
data-test-id="ndv-input-panel"
|
||||
>
|
||||
<template #header>
|
||||
@@ -209,6 +212,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPaneActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -455,6 +462,9 @@ export default defineComponent({
|
||||
}
|
||||
return truncated;
|
||||
},
|
||||
activatePane() {
|
||||
this.$emit('activatePane');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inputMode: {
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
:sessionId="sessionId"
|
||||
:readOnly="readOnly || hasForeignCredential"
|
||||
:isProductionExecutionPreview="isProductionExecutionPreview"
|
||||
:isPaneActive="isInputPaneActive"
|
||||
@activatePane="activateInputPane"
|
||||
@linkRun="onLinkRunToInput"
|
||||
@unlinkRun="() => onUnlinkRun('input')"
|
||||
@runChange="onRunInputIndexChange"
|
||||
@@ -73,6 +75,7 @@
|
||||
@execute="onNodeExecute"
|
||||
@tableMounted="onInputTableMounted"
|
||||
@itemHover="onInputItemHover"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #output>
|
||||
@@ -85,12 +88,15 @@
|
||||
:isReadOnly="readOnly || hasForeignCredential"
|
||||
:blockUI="blockUi && isTriggerNode && !isExecutableTriggerNode"
|
||||
:isProductionExecutionPreview="isProductionExecutionPreview"
|
||||
:isPaneActive="isOutputPaneActive"
|
||||
@activatePane="activateOutputPane"
|
||||
@linkRun="onLinkRunToOutput"
|
||||
@unlinkRun="() => onUnlinkRun('output')"
|
||||
@runChange="onRunOutputIndexChange"
|
||||
@openSettings="openSettings"
|
||||
@tableMounted="onOutputTableMounted"
|
||||
@itemHover="onOutputItemHover"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #main>
|
||||
@@ -211,6 +217,9 @@ export default defineComponent({
|
||||
pinDataDiscoveryTooltipVisible: false,
|
||||
avgInputRowHeight: 0,
|
||||
avgOutputRowHeight: 0,
|
||||
isInputPaneActive: false,
|
||||
isOutputPaneActive: false,
|
||||
isPairedItemHoveringEnabled: true,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -516,10 +525,7 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
onInputItemHover(e: { itemIndex: number; outputIndex: number } | null) {
|
||||
if (!this.inputNodeName) {
|
||||
return;
|
||||
}
|
||||
if (e === null) {
|
||||
if (e === null || !this.inputNodeName || !this.isPairedItemHoveringEnabled) {
|
||||
this.ndvStore.setHoveringItem(null);
|
||||
return;
|
||||
}
|
||||
@@ -533,7 +539,7 @@ export default defineComponent({
|
||||
this.ndvStore.setHoveringItem(item);
|
||||
},
|
||||
onOutputItemHover(e: { itemIndex: number; outputIndex: number } | null) {
|
||||
if (e === null || !this.activeNode) {
|
||||
if (e === null || !this.activeNode || !this.isPairedItemHoveringEnabled) {
|
||||
this.ndvStore.setHoveringItem(null);
|
||||
return;
|
||||
}
|
||||
@@ -717,6 +723,17 @@ export default defineComponent({
|
||||
onStopExecution() {
|
||||
this.$emit('stopExecution');
|
||||
},
|
||||
activateInputPane() {
|
||||
this.isInputPaneActive = true;
|
||||
this.isOutputPaneActive = false;
|
||||
},
|
||||
activateOutputPane() {
|
||||
this.isInputPaneActive = false;
|
||||
this.isOutputPaneActive = true;
|
||||
},
|
||||
onSearch(search: string) {
|
||||
this.isPairedItemHoveringEnabled = !search;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,12 +11,15 @@
|
||||
:sessionId="sessionId"
|
||||
:blockUI="blockUI"
|
||||
:isProductionExecutionPreview="isProductionExecutionPreview"
|
||||
:isPaneActive="isPaneActive"
|
||||
@activatePane="activatePane"
|
||||
paneType="output"
|
||||
@runChange="onRunIndexChange"
|
||||
@linkRun="onLinkRun"
|
||||
@unlinkRun="onUnlinkRun"
|
||||
@tableMounted="$emit('tableMounted', $event)"
|
||||
@itemHover="$emit('itemHover', $event)"
|
||||
@search="$emit('search', $event)"
|
||||
ref="runData"
|
||||
:data-output-type="outputMode"
|
||||
>
|
||||
@@ -166,6 +169,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPaneActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
|
||||
@@ -320,6 +327,9 @@ export default defineComponent({
|
||||
ndvEventBus.emit('setPositionByName', 'initial');
|
||||
}
|
||||
},
|
||||
activatePane() {
|
||||
this.$emit('activatePane');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="['run-data', $style.container]">
|
||||
<div :class="['run-data', $style.container]" @mouseover="activatePane">
|
||||
<n8n-callout
|
||||
v-if="canPinData && hasPinData && !editMode.enabled && !isProductionExecutionPreview"
|
||||
theme="secondary"
|
||||
@@ -49,9 +49,7 @@
|
||||
>
|
||||
<n8n-radio-buttons
|
||||
v-show="
|
||||
hasNodeRun &&
|
||||
((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0)) &&
|
||||
!editMode.enabled
|
||||
hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled
|
||||
"
|
||||
:modelValue="displayMode"
|
||||
:options="buttons"
|
||||
@@ -72,7 +70,7 @@
|
||||
/>
|
||||
<n8n-tooltip
|
||||
placement="bottom-end"
|
||||
v-if="canPinData && jsonData && jsonData.length > 0"
|
||||
v-if="canPinData && rawInputData.length"
|
||||
v-show="!editMode.enabled"
|
||||
:visible="
|
||||
isControlledPinDataTooltip
|
||||
@@ -135,37 +133,44 @@
|
||||
v-show="!editMode.enabled"
|
||||
data-test-id="run-selector"
|
||||
>
|
||||
<n8n-select
|
||||
size="small"
|
||||
:modelValue="runIndex"
|
||||
@update:modelValue="onRunIndexChange"
|
||||
@click.stop
|
||||
teleported
|
||||
>
|
||||
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
|
||||
<n8n-option
|
||||
v-for="option in maxRunIndex + 1"
|
||||
:label="getRunLabel(option)"
|
||||
:value="option - 1"
|
||||
:key="option"
|
||||
></n8n-option>
|
||||
</n8n-select>
|
||||
|
||||
<n8n-tooltip placement="right" v-if="canLinkRuns">
|
||||
<template #content>
|
||||
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
|
||||
</template>
|
||||
<n8n-icon-button
|
||||
class="linkRun"
|
||||
:icon="linkedRuns ? 'unlink' : 'link'"
|
||||
text
|
||||
type="tertiary"
|
||||
<div :class="$style.runSelectorWrapper">
|
||||
<n8n-select
|
||||
size="small"
|
||||
@click="toggleLinkRuns"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
|
||||
<slot name="run-info"></slot>
|
||||
:modelValue="runIndex"
|
||||
@update:modelValue="onRunIndexChange"
|
||||
@click.stop
|
||||
teleported
|
||||
>
|
||||
<template #prepend>{{ $locale.baseText('ndv.output.run') }}</template>
|
||||
<n8n-option
|
||||
v-for="option in maxRunIndex + 1"
|
||||
:label="getRunLabel(option)"
|
||||
:value="option - 1"
|
||||
:key="option"
|
||||
></n8n-option>
|
||||
</n8n-select>
|
||||
<n8n-tooltip placement="right" v-if="canLinkRuns">
|
||||
<template #content>
|
||||
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
|
||||
</template>
|
||||
<n8n-icon-button
|
||||
class="linkRun"
|
||||
:icon="linkedRuns ? 'unlink' : 'link'"
|
||||
text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
@click="toggleLinkRuns"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
<slot name="run-info"></slot>
|
||||
</div>
|
||||
<run-data-search
|
||||
v-if="showIOSearch"
|
||||
v-model="search"
|
||||
:paneType="paneType"
|
||||
:isAreaActive="isPaneActive"
|
||||
@focus="activatePane"
|
||||
/>
|
||||
</div>
|
||||
<slot name="before-data" />
|
||||
|
||||
@@ -179,18 +184,48 @@
|
||||
:options="branches"
|
||||
@update:modelValue="onBranchChange"
|
||||
/>
|
||||
<run-data-search
|
||||
v-if="showIOSearch"
|
||||
v-model="search"
|
||||
:paneType="paneType"
|
||||
:isAreaActive="isPaneActive"
|
||||
@focus="activatePane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
hasNodeRun && dataCount > 0 && maxRunIndex === 0 && !isArtificialRecoveredEventItem
|
||||
hasNodeRun &&
|
||||
((dataCount > 0 && maxRunIndex === 0) || search) &&
|
||||
!isArtificialRecoveredEventItem
|
||||
"
|
||||
v-show="!editMode.enabled"
|
||||
:class="$style.itemsCount"
|
||||
data-test-id="ndv-items-count"
|
||||
>
|
||||
<n8n-text>
|
||||
{{ dataCount }} {{ $locale.baseText('ndv.output.items', { adjustToNumber: dataCount }) }}
|
||||
<n8n-text v-if="search">
|
||||
{{
|
||||
$locale.baseText('ndv.search.items', {
|
||||
adjustToNumber: unfilteredDataCount,
|
||||
interpolate: { matched: dataCount, total: unfilteredDataCount },
|
||||
})
|
||||
}}
|
||||
</n8n-text>
|
||||
<n8n-text v-else>
|
||||
{{
|
||||
$locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: dataCount,
|
||||
interpolate: { count: dataCount },
|
||||
})
|
||||
}}
|
||||
</n8n-text>
|
||||
<run-data-search
|
||||
v-if="showIOSearch"
|
||||
v-model="search"
|
||||
:paneType="paneType"
|
||||
:isAreaActive="isPaneActive"
|
||||
@focus="activatePane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.dataContainer" ref="dataContainer" data-test-id="ndv-data-container">
|
||||
@@ -258,15 +293,31 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasNodeRun && jsonData && jsonData.length === 0 && branches.length > 1"
|
||||
v-else-if="
|
||||
hasNodeRun && (!unfilteredDataCount || (search && !dataCount)) && branches.length > 1
|
||||
"
|
||||
:class="$style.center"
|
||||
>
|
||||
<n8n-text>
|
||||
<div v-if="search">
|
||||
<n8n-text tag="h3" size="large">{{
|
||||
$locale.baseText('ndv.search.noMatch.title')
|
||||
}}</n8n-text>
|
||||
<n8n-text>
|
||||
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
|
||||
<template #link>
|
||||
<a href="#" @click="onSearchClear">
|
||||
{{ $locale.baseText('ndv.search.noMatch.description.link') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</n8n-text>
|
||||
</div>
|
||||
<n8n-text v-else>
|
||||
{{ noDataInBranchMessage }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasNodeRun && jsonData && jsonData.length === 0" :class="$style.center">
|
||||
<div v-else-if="hasNodeRun && !inputData.length && !search" :class="$style.center">
|
||||
<slot name="no-output-data">xxx</slot>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +354,7 @@
|
||||
hasNodeRun &&
|
||||
displayMode === 'table' &&
|
||||
binaryData.length > 0 &&
|
||||
jsonData.length === 1 &&
|
||||
inputData.length === 1 &&
|
||||
Object.keys(jsonData[0] || {}).length === 0
|
||||
"
|
||||
:class="$style.center"
|
||||
@@ -316,6 +367,21 @@
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showIoSearchNoMatchContent" :class="$style.center">
|
||||
<n8n-text tag="h3" size="large">{{
|
||||
$locale.baseText('ndv.search.noMatch.title')
|
||||
}}</n8n-text>
|
||||
<n8n-text>
|
||||
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
|
||||
<template #link>
|
||||
<a href="#" @click="onSearchClear">
|
||||
{{ $locale.baseText('ndv.search.noMatch.description.link') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
||||
<Suspense v-else-if="hasNodeRun && displayMode === 'table'">
|
||||
<run-data-table
|
||||
:node="node"
|
||||
@@ -325,7 +391,8 @@
|
||||
:runIndex="runIndex"
|
||||
:pageOffset="currentPageOffset"
|
||||
:totalRuns="maxRunIndex"
|
||||
:hasDefaultHoverState="paneType === 'input'"
|
||||
:hasDefaultHoverState="paneType === 'input' && !search"
|
||||
:search="search"
|
||||
@mounted="$emit('tableMounted', $event)"
|
||||
@activeRowChanged="onItemHover"
|
||||
@displayModeChange="onDisplayModeChange"
|
||||
@@ -343,6 +410,7 @@
|
||||
:distanceFromActive="distanceFromActive"
|
||||
:runIndex="runIndex"
|
||||
:totalRuns="maxRunIndex"
|
||||
:search="search"
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -359,6 +427,7 @@
|
||||
:paneType="paneType"
|
||||
:runIndex="runIndex"
|
||||
:totalRuns="maxRunIndex"
|
||||
:search="search"
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -461,6 +530,7 @@
|
||||
!isArtificialRecoveredEventItem
|
||||
"
|
||||
v-show="!editMode.enabled"
|
||||
data-test-id="ndv-data-pagination"
|
||||
>
|
||||
<el-pagination
|
||||
background
|
||||
@@ -542,7 +612,7 @@ import { pinData } from '@/mixins/pinData';
|
||||
import type { PinDataSource } from '@/mixins/pinData';
|
||||
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
||||
import { dataPinningEventBus } from '@/event-bus';
|
||||
import { clearJsonKey, executionDataToJson, isEmpty } from '@/utils';
|
||||
import { clearJsonKey, executionDataToJson, isEmpty, searchInObject } from '@/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
@@ -553,6 +623,7 @@ const RunDataTable = defineAsyncComponent(async () => import('@/components/RunDa
|
||||
const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue'));
|
||||
const RunDataSchema = defineAsyncComponent(async () => import('@/components/RunDataSchema.vue'));
|
||||
const RunDataHtml = defineAsyncComponent(async () => import('@/components/RunDataHtml.vue'));
|
||||
const RunDataSearch = defineAsyncComponent(async () => import('@/components/RunDataSearch.vue'));
|
||||
|
||||
export type EnterEditModeArgs = {
|
||||
origin: 'editIconButton' | 'insertTestDataLink';
|
||||
@@ -569,6 +640,7 @@ export default defineComponent({
|
||||
RunDataJson,
|
||||
RunDataSchema,
|
||||
RunDataHtml,
|
||||
RunDataSearch,
|
||||
},
|
||||
props: {
|
||||
nodeUi: {
|
||||
@@ -619,6 +691,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPaneActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@@ -643,6 +719,7 @@ export default defineComponent({
|
||||
|
||||
pinDataDiscoveryTooltipVisible: false,
|
||||
isControlledPinDataTooltip: false,
|
||||
search: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -656,7 +733,10 @@ export default defineComponent({
|
||||
branchIndex: this.currentOutputIndex,
|
||||
});
|
||||
|
||||
if (this.paneType === 'output') this.setDisplayMode();
|
||||
if (this.paneType === 'output') {
|
||||
this.setDisplayMode();
|
||||
this.activatePane();
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.hidePinDataDiscoveryTooltip();
|
||||
@@ -777,6 +857,9 @@ export default defineComponent({
|
||||
dataCount(): number {
|
||||
return this.getDataCount(this.runIndex, this.currentOutputIndex);
|
||||
},
|
||||
unfilteredDataCount(): number {
|
||||
return this.pinData ? this.pinData.length : this.rawInputData.length;
|
||||
},
|
||||
dataSizeInMB(): string {
|
||||
return (this.dataSize / 1024 / 1000).toLocaleString();
|
||||
},
|
||||
@@ -828,7 +911,8 @@ export default defineComponent({
|
||||
return this.getRawInputData(this.runIndex, this.currentOutputIndex, this.connectionType);
|
||||
},
|
||||
inputData(): INodeExecutionData[] {
|
||||
return this.getPinDataOrLiveData(this.rawInputData);
|
||||
const pinOrLiveData = this.getPinDataOrLiveData(this.rawInputData);
|
||||
return this.getFilteredData(pinOrLiveData);
|
||||
},
|
||||
inputDataPage(): INodeExecutionData[] {
|
||||
const offset = this.pageSize * (this.currentPage - 1);
|
||||
@@ -866,8 +950,17 @@ export default defineComponent({
|
||||
if (this.overrideOutputs && !this.overrideOutputs.includes(i)) {
|
||||
continue;
|
||||
}
|
||||
const totalItemsCount = this.getRawInputData(this.runIndex, i).length;
|
||||
const itemsCount = this.getDataCount(this.runIndex, i);
|
||||
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount });
|
||||
const items = this.search
|
||||
? this.$locale.baseText('ndv.search.items', {
|
||||
adjustToNumber: totalItemsCount,
|
||||
interpolate: { matched: itemsCount, total: totalItemsCount },
|
||||
})
|
||||
: this.$locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: itemsCount,
|
||||
interpolate: { count: itemsCount },
|
||||
});
|
||||
let outputName = this.getOutputName(i);
|
||||
|
||||
if (`${outputName}` === `${i}`) {
|
||||
@@ -881,7 +974,10 @@ export default defineComponent({
|
||||
outputName = capitalize(`${this.getOutputName(i)}${appendBranchWord}`);
|
||||
}
|
||||
branches.push({
|
||||
label: itemsCount ? `${outputName} (${itemsCount} ${items})` : outputName,
|
||||
label:
|
||||
(this.search && itemsCount) || totalItemsCount
|
||||
? `${outputName} (${items})`
|
||||
: outputName,
|
||||
value: i,
|
||||
});
|
||||
}
|
||||
@@ -901,6 +997,12 @@ export default defineComponent({
|
||||
readOnlyEnv(): boolean {
|
||||
return this.sourceControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
showIOSearch(): boolean {
|
||||
return this.hasNodeRun && !this.hasRunError;
|
||||
},
|
||||
showIoSearchNoMatchContent(): boolean {
|
||||
return this.hasNodeRun && !this.inputData.length && this.search;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getResolvedNodeOutputs() {
|
||||
@@ -1158,10 +1260,13 @@ export default defineComponent({
|
||||
getRunLabel(option: number) {
|
||||
let itemsCount = 0;
|
||||
for (let i = 0; i <= this.maxOutputIndex; i++) {
|
||||
itemsCount += this.getDataCount(option - 1, i);
|
||||
itemsCount += this.getPinDataOrLiveData(this.getRawInputData(option - 1, i)).length;
|
||||
}
|
||||
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount });
|
||||
const itemsLabel = itemsCount > 0 ? ` (${itemsCount} ${items})` : '';
|
||||
const items = this.$locale.baseText('ndv.output.items', {
|
||||
adjustToNumber: itemsCount,
|
||||
interpolate: { count: itemsCount },
|
||||
});
|
||||
const itemsLabel = itemsCount > 0 ? ` (${items})` : '';
|
||||
return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex + 1) + itemsLabel;
|
||||
},
|
||||
getRawInputData(
|
||||
@@ -1201,6 +1306,14 @@ export default defineComponent({
|
||||
}
|
||||
return inputData;
|
||||
},
|
||||
getFilteredData(inputData: INodeExecutionData[]): INodeExecutionData[] {
|
||||
if (!this.search) {
|
||||
return inputData;
|
||||
}
|
||||
|
||||
this.currentPage = 1;
|
||||
return inputData.filter(({ json }) => searchInObject(json, this.search));
|
||||
},
|
||||
getDataCount(
|
||||
runIndex: number,
|
||||
outputIndex: number,
|
||||
@@ -1215,7 +1328,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const rawInputData = this.getRawInputData(runIndex, outputIndex, connectionType);
|
||||
return this.getPinDataOrLiveData(rawInputData).length;
|
||||
const pinOrLiveData = this.getPinDataOrLiveData(rawInputData);
|
||||
return this.getFilteredData(pinOrLiveData).length;
|
||||
},
|
||||
init() {
|
||||
// Reset the selected output index every time another node gets selected
|
||||
@@ -1347,6 +1461,13 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
},
|
||||
activatePane() {
|
||||
this.$emit('activatePane');
|
||||
},
|
||||
onSearchClear() {
|
||||
this.search = '';
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
node() {
|
||||
@@ -1384,6 +1505,9 @@ export default defineComponent({
|
||||
branchIndex,
|
||||
});
|
||||
},
|
||||
search(newSearch: string) {
|
||||
this.$emit('search', newSearch);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1465,24 +1589,32 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.itemsCount {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-left: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.runSelector {
|
||||
max-width: 210px;
|
||||
margin-left: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
padding-left: var(--spacing-s);
|
||||
padding-bottom: var(--spacing-s);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.runSelectorWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
margin-right: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@@ -1645,3 +1777,14 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.highlight) {
|
||||
background-color: #f7dc55;
|
||||
color: black;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 0 1px;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
[$style.mappable]: mappingEnabled,
|
||||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
>"{{ node.key }}"</span
|
||||
>
|
||||
v-html="highlightSearchTerm(node.key)"
|
||||
/>
|
||||
</template>
|
||||
<template #renderNodeValue="{ node }">
|
||||
<span v-if="isNaN(node.index)">{{ getContent(node.content) }}</span>
|
||||
<span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
|
||||
<span
|
||||
v-else
|
||||
data-target="mappable"
|
||||
@@ -60,8 +60,8 @@
|
||||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
class="ph-no-capture"
|
||||
>{{ getContent(node.content) }}</span
|
||||
>
|
||||
v-html="highlightSearchTerm(node.content)"
|
||||
/>
|
||||
</template>
|
||||
</vue-json-pretty>
|
||||
</draggable>
|
||||
@@ -74,7 +74,7 @@ import type { PropType } from 'vue';
|
||||
import VueJsonPretty from 'vue-json-pretty';
|
||||
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import Draggable from '@/components/Draggable.vue';
|
||||
import { executionDataToJson, isString, shorten } from '@/utils';
|
||||
import { executionDataToJson, highlightText, isString, sanitizeHtml, shorten } from '@/utils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
import { mapStores } from 'pinia';
|
||||
@@ -125,6 +125,9 @@ export default defineComponent({
|
||||
totalRuns: {
|
||||
type: Number,
|
||||
},
|
||||
search: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const selectedJsonPath = ref(nonExistingJsonPath);
|
||||
@@ -194,6 +197,9 @@ 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>
|
||||
|
||||
@@ -18,6 +18,7 @@ type Props = {
|
||||
totalRuns: number;
|
||||
paneType: 'input' | 'output';
|
||||
node: INodeUi | null;
|
||||
search: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -91,6 +92,7 @@ const onDragEnd = (el: HTMLElement) => {
|
||||
:draggingPath="draggingPath"
|
||||
:distanceFromActive="distanceFromActive"
|
||||
:node="node"
|
||||
:search="search"
|
||||
/>
|
||||
</div>
|
||||
</draggable>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { INodeUi, Schema } from '@/Interface';
|
||||
import { checkExhaustive, shorten } from '@/utils';
|
||||
import { checkExhaustive, highlightText, sanitizeHtml, shorten } from '@/utils';
|
||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
|
||||
type Props = {
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
draggingPath: string;
|
||||
distanceFromActive: number;
|
||||
node: INodeUi | null;
|
||||
search: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -26,8 +27,12 @@ const isFlat = computed(
|
||||
Array.isArray(props.schema.value) &&
|
||||
props.schema.value.every((v) => !Array.isArray(v.value)),
|
||||
);
|
||||
const key = computed((): string | undefined =>
|
||||
isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key,
|
||||
const key = computed((): string | undefined => {
|
||||
const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
|
||||
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
|
||||
});
|
||||
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,
|
||||
@@ -92,8 +97,8 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||
data-target="mappable"
|
||||
>
|
||||
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
||||
<span v-if="isSchemaParentTypeArray">{{ parent.key }}</span>
|
||||
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }">{{ key }}</span>
|
||||
<span v-if="isSchemaParentTypeArray" v-html="parentKey" />
|
||||
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="text" :class="$style.text">{{ text }}</span>
|
||||
@@ -115,6 +120,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
||||
:distanceFromActive="distanceFromActive"
|
||||
:node="node"
|
||||
:style="{ transitionDelay: transitionDelay(i) }"
|
||||
:search="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
124
packages/editor-ui/src/components/RunDataSearch.vue
Normal file
124
packages/editor-ui/src/components/RunDataSearch.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from '@/composables';
|
||||
import type { NodePanelType } from '@/Interface';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
paneType?: NodePanelType;
|
||||
isAreaActive?: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_WIDTH = '34px';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: Props['modelValue']): void;
|
||||
(event: 'focus'): void;
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
paneType: 'output',
|
||||
isAreaActive: false,
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const maxWidth = ref(INITIAL_WIDTH);
|
||||
const opened = ref(false);
|
||||
const focused = ref(false);
|
||||
const placeholder = computed(() =>
|
||||
props.paneType === 'input'
|
||||
? locale.baseText('ndv.search.placeholder.input')
|
||||
: locale.baseText('ndv.search.placeholder.output'),
|
||||
);
|
||||
|
||||
const documentKeyHandler = (event: KeyboardEvent) => {
|
||||
const isTargetAnyFormElement =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement;
|
||||
if (event.key === '/' && !focused.value && props.isAreaActive && !isTargetAnyFormElement) {
|
||||
inputRef.value?.focus();
|
||||
inputRef.value?.select();
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchUpdate = (value: string) => {
|
||||
emit('update:modelValue', value);
|
||||
};
|
||||
const onFocus = () => {
|
||||
opened.value = true;
|
||||
focused.value = true;
|
||||
maxWidth.value = '30%';
|
||||
inputRef.value?.select();
|
||||
emit('focus');
|
||||
};
|
||||
const onBlur = () => {
|
||||
focused.value = false;
|
||||
if (!props.modelValue) {
|
||||
opened.value = false;
|
||||
maxWidth.value = INITIAL_WIDTH;
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
document.addEventListener('keyup', documentKeyHandler);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keyup', documentKeyHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-input
|
||||
ref="inputRef"
|
||||
data-test-id="ndv-search"
|
||||
:class="{
|
||||
[$style.ioSearch]: true,
|
||||
[$style.ioSearchOpened]: opened,
|
||||
}"
|
||||
:style="{ maxWidth }"
|
||||
:modelValue="modelValue"
|
||||
:placeholder="placeholder"
|
||||
size="small"
|
||||
@update:modelValue="onSearchUpdate"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<template #prefix>
|
||||
<n8n-icon :class="$style.ioSearchIcon" icon="search" />
|
||||
</template>
|
||||
</n8n-input>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/css-animation-helpers.scss';
|
||||
|
||||
.ioSearch {
|
||||
margin-right: var(--spacing-s);
|
||||
transition: max-width 0.3s $ease-out-expo;
|
||||
|
||||
.ioSearchIcon {
|
||||
color: var(--color-foreground-xdark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.ioSearchOpened {
|
||||
.ioSearchIcon {
|
||||
cursor: default;
|
||||
}
|
||||
input {
|
||||
border: var(--input-border-color, var(--border-color-base))
|
||||
var(--input-border-style, var(--border-style-base)) var(--border-width-base);
|
||||
background: var(--input-background-color, var(--color-foreground-xlight));
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,7 @@
|
||||
[$style.draggingHeader]: isDragging,
|
||||
}"
|
||||
>
|
||||
<span>{{ column || ' ' }}</span>
|
||||
<span v-html="highlightSearchTerm(column || '')" />
|
||||
<div :class="$style.dragButton">
|
||||
<font-awesome-icon icon="grip-vertical" />
|
||||
</div>
|
||||
@@ -120,8 +120,8 @@
|
||||
<span
|
||||
v-if="isSimple(data)"
|
||||
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
|
||||
>{{ getValueToRender(data) }}</span
|
||||
>
|
||||
v-html="highlightSearchTerm(data)"
|
||||
/>
|
||||
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
|
||||
<template #label="{ label, path }">
|
||||
<span
|
||||
@@ -141,9 +141,10 @@
|
||||
>
|
||||
</template>
|
||||
<template #value="{ value }">
|
||||
<span :class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }">
|
||||
{{ getValueToRender(value) }}
|
||||
</span>
|
||||
<span
|
||||
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
|
||||
v-html="highlightSearchTerm(value)"
|
||||
/>
|
||||
</template>
|
||||
</n8n-tree>
|
||||
</td>
|
||||
@@ -160,7 +161,7 @@ import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import type { INodeUi, ITableData, NDVState } from '@/Interface';
|
||||
import { getPairedItemId, shorten } from '@/utils';
|
||||
import { getPairedItemId, highlightText, sanitizeHtml, shorten } from '@/utils';
|
||||
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import Draggable from './Draggable.vue';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
@@ -205,6 +206,9 @@ export default defineComponent({
|
||||
hasDefaultHoverState: {
|
||||
type: Boolean,
|
||||
},
|
||||
search: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -360,7 +364,7 @@ export default defineComponent({
|
||||
value === undefined
|
||||
);
|
||||
},
|
||||
getValueToRender(value: unknown) {
|
||||
getValueToRender(value: unknown): string {
|
||||
if (value === '') {
|
||||
return this.$locale.baseText('runData.emptyString');
|
||||
}
|
||||
@@ -376,8 +380,14 @@ export default defineComponent({
|
||||
if (value === null || value === undefined) {
|
||||
return `[${value}]`;
|
||||
}
|
||||
if (value === true || value === false || typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
highlightSearchTerm(value: string): string {
|
||||
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
|
||||
},
|
||||
onDragStart() {
|
||||
this.draggedColumn = true;
|
||||
this.ndvStore.resetMappingTelemetry();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import RunDataSearch from '@/components/RunDataSearch.vue';
|
||||
import { useSettingsStore, useUIStore } from '@/stores';
|
||||
|
||||
const renderComponent = createComponentRenderer(RunDataSearch);
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
|
||||
describe('RunDataSearch', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
uiStore = useUIStore();
|
||||
settingsStore = useSettingsStore();
|
||||
});
|
||||
|
||||
it('should not be focused on keyboard shortcut when area is not active', async () => {
|
||||
const { emitted } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.keyboard('/');
|
||||
expect(emitted().focus).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should be focused on click regardless of active area and keyboard shortcut should work after', async () => {
|
||||
const { getByRole, emitted, rerender } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByRole('textbox'));
|
||||
expect(emitted().focus).toHaveLength(1);
|
||||
await userEvent.click(document.body);
|
||||
|
||||
await rerender({ isAreaActive: true });
|
||||
await userEvent.keyboard('/');
|
||||
expect(emitted().focus).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should be focused twice if area is already active', async () => {
|
||||
const { getByRole, emitted } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
modelValue: '',
|
||||
isAreaActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByRole('textbox'));
|
||||
expect(emitted().focus).toHaveLength(1);
|
||||
await userEvent.click(document.body);
|
||||
|
||||
await userEvent.keyboard('/');
|
||||
expect(emitted().focus).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should select all text when focused', async () => {
|
||||
vi.spyOn(settingsStore, 'isEnterpriseFeatureEnabled', 'get').mockReturnValue(() => true);
|
||||
const { getByRole, emitted } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
modelValue: '',
|
||||
isAreaActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const input = getByRole('textbox');
|
||||
|
||||
await userEvent.click(input);
|
||||
expect(emitted().focus).toHaveLength(1);
|
||||
await userEvent.type(input, 'test');
|
||||
|
||||
await userEvent.click(document.body);
|
||||
await userEvent.click(input);
|
||||
expect(emitted().focus).toHaveLength(2);
|
||||
|
||||
const selectionStart = input.selectionStart;
|
||||
const selectionEnd = input.selectionEnd;
|
||||
const isSelected = selectionStart === 0 && selectionEnd === input.value.length;
|
||||
|
||||
expect(isSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -621,6 +621,7 @@ export const ALLOWED_HTML_TAGS = [
|
||||
'small',
|
||||
'details',
|
||||
'summary',
|
||||
'mark',
|
||||
];
|
||||
|
||||
export const CLOUD_CHANGE_PLAN_PAGE = window.location.host.includes('stage-app.n8n.cloud')
|
||||
|
||||
@@ -779,7 +779,7 @@
|
||||
"ndv.output.all": "all",
|
||||
"ndv.output.branch": "Branch",
|
||||
"ndv.output.executing": "Executing node...",
|
||||
"ndv.output.items": "item | items",
|
||||
"ndv.output.items": "{count} item | {count} items",
|
||||
"ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via",
|
||||
"ndv.output.noOutputData.message.settings": "Settings",
|
||||
"ndv.output.noOutputData.message.settingsOption": "> “Always Output Data”.",
|
||||
@@ -1756,6 +1756,12 @@
|
||||
"ndv.trigger.pollingNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then n8n will regularly check {service} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
|
||||
"ndv.trigger.pollingNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Your workflow will also execute automatically</b>, since it's activated. n8n will regularly check {app_name} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
|
||||
"ndv.trigger.webhookBasedNode.action": "Pull in events from {name}",
|
||||
"ndv.search.placeholder.output": "Filter output",
|
||||
"ndv.search.placeholder.input": "Filter input",
|
||||
"ndv.search.noMatch.title": "No matching items",
|
||||
"ndv.search.noMatch.description": "Try changing or {link} the filter to see more",
|
||||
"ndv.search.noMatch.description.link": "clearing",
|
||||
"ndv.search.items": "{matched} of {total} item | {matched} of {total} items",
|
||||
"updatesPanel.andIs": "and is",
|
||||
"updatesPanel.behindTheLatest": "behind the latest and greatest n8n",
|
||||
"updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version",
|
||||
|
||||
41
packages/editor-ui/src/utils/__tests__/htmlUtils.test.ts
Normal file
41
packages/editor-ui/src/utils/__tests__/htmlUtils.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { highlightText } from '@/utils';
|
||||
|
||||
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,3 +63,9 @@ 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;
|
||||
};
|
||||
|
||||
@@ -16,5 +16,5 @@ export const searchInObject = (obj: ObjectOrArray, searchString: string): boolea
|
||||
(Array.isArray(obj) ? obj : Object.entries(obj)).some((entry) =>
|
||||
isObjectOrArray(entry)
|
||||
? searchInObject(entry, searchString)
|
||||
: entry?.toString().includes(searchString),
|
||||
: entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user