feat(editor): Node IO filter (#7503)

Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
Csaba Tuncsik
2023-11-15 16:19:48 +01:00
committed by GitHub
parent 93103c0b08
commit 18817651ec
18 changed files with 1331 additions and 85 deletions

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -52,7 +52,7 @@
[$style.draggingHeader]: isDragging,
}"
>
<span>{{ column || '&nbsp;' }}</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();

View File

@@ -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);
});
});

View File

@@ -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')

View File

@@ -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",

View 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);
});
});

View File

@@ -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;
};

View File

@@ -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()),
);