n8n/packages/editor-ui/src/components/RunData.vue
Mutasem Aldmour a732374f24
fix: Update Posthog no-capture (#5693)
* feat: add no-capture class for schema view

* fix: add ee nocapture

* fix: update no capture

* fix: update ndv capturing

* feat: remove no capture from empty resources list

* lint: fix

* test: update tests

* test: add json view tests

* fix: update more class to cover

* lint: fix
2023-03-23 11:20:11 +01:00

1605 lines
42 KiB
Vue

<template>
<div :class="$style.container">
<n8n-callout
v-if="canPinData && hasPinData && !editMode.enabled && !isProductionExecutionPreview"
theme="secondary"
icon="thumbtack"
:class="$style.pinnedDataCallout"
>
{{ $locale.baseText('runData.pindata.thisDataIsPinned') }}
<span class="ml-4xs" v-if="!isReadOnly">
<n8n-link
theme="secondary"
size="small"
underline
bold
@click="onTogglePinData({ source: 'banner-link' })"
>
{{ $locale.baseText('runData.pindata.unpin') }}
</n8n-link>
</span>
<template #trailingContent>
<n8n-link
:to="dataPinningDocsUrl"
size="small"
theme="secondary"
bold
underline
@click="onClickDataPinningDocsLink"
>
{{ $locale.baseText('runData.pindata.learnMore') }}
</n8n-link>
</template>
</n8n-callout>
<BinaryDataDisplay
:windowVisible="binaryDataDisplayVisible"
:displayData="binaryDataDisplayData"
@close="closeBinaryDataDisplay"
/>
<div :class="$style.header">
<slot name="header"></slot>
<div
v-show="!hasRunError"
@click.stop
:class="$style.displayModes"
data-test-id="run-data-pane-header"
>
<n8n-radio-buttons
v-show="
hasNodeRun &&
((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0)) &&
!editMode.enabled
"
:value="displayMode"
:options="buttons"
@input="onDisplayModeChange"
data-test-id="ndv-run-data-display-mode"
/>
<n8n-icon-button
v-if="canPinData && !isReadOnly"
v-show="!editMode.enabled"
:title="$locale.baseText('runData.editOutput')"
:circle="false"
:disabled="node.disabled"
class="ml-2xs"
icon="pencil-alt"
type="tertiary"
data-test-id="ndv-edit-pinned-data"
@click="enterEditMode({ origin: 'editIconButton' })"
/>
<n8n-tooltip
placement="bottom-end"
v-if="canPinData && jsonData && jsonData.length > 0"
v-show="!editMode.enabled"
:value="pinDataDiscoveryTooltipVisible"
:manual="isControlledPinDataTooltip"
>
<template #content v-if="!isControlledPinDataTooltip">
<div :class="$style.tooltipContainer">
<strong>{{ $locale.baseText('ndv.pinData.pin.title') }}</strong>
<n8n-text size="small" tag="p">
{{ $locale.baseText('ndv.pinData.pin.description') }}
<n8n-link :to="dataPinningDocsUrl" size="small">
{{ $locale.baseText('ndv.pinData.pin.link') }}
</n8n-link>
</n8n-text>
</div>
</template>
<template #content v-else>
<div :class="$style.tooltipContainer">
{{ $locale.baseText('node.discovery.pinData.ndv') }}
</div>
</template>
<n8n-icon-button
:class="['ml-2xs', $style.pinDataButton]"
type="tertiary"
:active="hasPinData"
icon="thumbtack"
:disabled="editMode.enabled || (inputData.length === 0 && !hasPinData) || isReadOnly"
@click="onTogglePinData({ source: 'pin-icon-click' })"
data-test-id="ndv-pin-data"
/>
</n8n-tooltip>
<div :class="$style.editModeActions" v-show="editMode.enabled">
<n8n-button
type="tertiary"
:label="$locale.baseText('runData.editor.cancel')"
@click="onClickCancelEdit"
/>
<n8n-button
class="ml-2xs"
type="primary"
:label="$locale.baseText('runData.editor.save')"
@click="onClickSaveEdit"
/>
</div>
</div>
</div>
<div :class="$style.runSelector" v-if="maxRunIndex > 0" v-show="!editMode.enabled">
<n8n-select
size="small"
:value="runIndex"
@input="onRunIndexChange"
@click.stop
popper-append-to-body
>
<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"
:content="$locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint')"
>
<n8n-icon-button
v-if="linkedRuns"
icon="unlink"
text
type="tertiary"
size="small"
@click="unlinkRun"
/>
<n8n-icon-button v-else icon="link" text type="tertiary" size="small" @click="linkRun" />
</n8n-tooltip>
<slot name="run-info"></slot>
</div>
<div v-if="maxOutputIndex > 0 && branches.length > 1" :class="$style.tabs">
<n8n-tabs :value="currentOutputIndex" @input="onBranchChange" :options="branches" />
</div>
<div
v-else-if="hasNodeRun && dataCount > 0 && maxRunIndex === 0 && !isArtificalRecoveredEventItem"
v-show="!editMode.enabled"
:class="$style.itemsCount"
>
<n8n-text>
{{ dataCount }} {{ $locale.baseText('ndv.output.items', { adjustToNumber: dataCount }) }}
</n8n-text>
</div>
<div :class="$style.dataContainer" ref="dataContainer" data-test-id="ndv-data-container">
<div v-if="isExecuting" :class="$style.center" data-test-id="ndv-executing">
<div :class="$style.spinner"><n8n-spinner type="ring" /></div>
<n8n-text>{{ executingMessage }}</n8n-text>
</div>
<div v-else-if="editMode.enabled" :class="$style.editMode">
<div :class="[$style.editModeBody, 'ignore-key-press']">
<code-editor
:value="editMode.value"
:options="{ scrollBeyondLastLine: false }"
type="json"
@input="ndvStore.setOutputPanelEditModeValue($event)"
/>
</div>
<div :class="$style.editModeFooter">
<n8n-info-tip :bold="false" :class="$style.editModeFooterInfotip">
{{ $locale.baseText('runData.editor.copyDataInfo') }}
<n8n-link :to="dataEditingDocsUrl" size="small">
{{ $locale.baseText('generic.learnMore') }}
</n8n-link>
</n8n-info-tip>
</div>
</div>
<div
v-else-if="paneType === 'output' && hasSubworkflowExecutionError"
:class="$style.stretchVertically"
>
<NodeErrorView :error="subworkflowExecutionError" :class="$style.errorDisplay" />
</div>
<div v-else-if="!hasNodeRun" :class="$style.center">
<slot name="node-not-run"></slot>
</div>
<div v-else-if="paneType === 'input' && node.disabled" :class="$style.center">
<n8n-text>
{{ $locale.baseText('ndv.input.disabled', { interpolate: { nodeName: node.name } }) }}
<n8n-link @click="enableNode">
{{ $locale.baseText('ndv.input.disabled.cta') }}
</n8n-link>
</n8n-text>
</div>
<div v-else-if="hasNodeRun && isArtificalRecoveredEventItem" :class="$style.center">
<slot name="recovered-artifical-output-data"></slot>
</div>
<div v-else-if="hasNodeRun && hasRunError" :class="$style.stretchVertically">
<n8n-text v-if="isPaneTypeInput" :class="$style.center" size="large" tag="p" bold>
{{
$locale.baseText('nodeErrorView.inputPanel.previousNodeError.title', {
interpolate: { nodeName: node.name },
})
}}
<n8n-link @click="goToErroredNode">
{{ $locale.baseText('nodeErrorView.inputPanel.previousNodeError.text') }}
</n8n-link>
</n8n-text>
<NodeErrorView
v-else
:error="workflowRunData[node.name][runIndex].error"
:class="$style.dataDisplay"
/>
</div>
<div
v-else-if="hasNodeRun && jsonData && jsonData.length === 0 && branches.length > 1"
:class="$style.center"
>
<n8n-text>
{{ noDataInBranchMessage }}
</n8n-text>
</div>
<div v-else-if="hasNodeRun && jsonData && jsonData.length === 0" :class="$style.center">
<slot name="no-output-data">xxx</slot>
</div>
<div v-else-if="hasNodeRun && !showData" :class="$style.center">
<n8n-text :bold="true" color="text-dark" size="large">{{ tooMuchDataTitle }}</n8n-text>
<n8n-text align="center" tag="div"
><span
v-html="
$locale.baseText('ndv.output.tooMuchData.message', {
interpolate: { size: dataSizeInMB },
})
"
></span
></n8n-text>
<n8n-button
outline
:label="$locale.baseText('ndv.output.tooMuchData.showDataAnyway')"
@click="showTooMuchData"
/>
</div>
<div
v-else-if="
hasNodeRun &&
displayMode === 'table' &&
binaryData.length > 0 &&
jsonData.length === 1 &&
Object.keys(jsonData[0] || {}).length === 0
"
:class="$style.center"
>
<n8n-text>
{{ $locale.baseText('runData.switchToBinary.info') }}
<a @click="switchToBinary">
{{ $locale.baseText('runData.switchToBinary.binary') }}
</a>
</n8n-text>
</div>
<run-data-table
v-else-if="hasNodeRun && displayMode === 'table'"
:node="node"
:inputData="inputData"
:mappingEnabled="mappingEnabled"
:distanceFromActive="distanceFromActive"
:runIndex="runIndex"
:pageOffset="currentPageOffset"
:totalRuns="maxRunIndex"
:hasDefaultHoverState="paneType === 'input'"
@mounted="$emit('tableMounted', $event)"
@activeRowChanged="onItemHover"
@displayModeChange="onDisplayModeChange"
/>
<run-data-json
v-else-if="hasNodeRun && displayMode === 'json'"
:paneType="paneType"
:editMode="editMode"
:sessioId="sessionId"
:node="node"
:inputData="inputData"
:mappingEnabled="mappingEnabled"
:distanceFromActive="distanceFromActive"
:runIndex="runIndex"
:totalRuns="maxRunIndex"
/>
<run-data-html
v-else-if="hasNodeRun && isPaneTypeOutput && displayMode === 'html'"
:inputData="inputData"
/>
<run-data-schema
v-else-if="hasNodeRun && displayMode === 'schema'"
:data="jsonData"
:mappingEnabled="mappingEnabled"
:distanceFromActive="distanceFromActive"
:node="node"
:paneType="paneType"
:runIndex="runIndex"
:totalRuns="maxRunIndex"
/>
<div v-else-if="displayMode === 'binary' && binaryData.length === 0" :class="$style.center">
<n8n-text align="center" tag="div">{{
$locale.baseText('runData.noBinaryDataFound')
}}</n8n-text>
</div>
<div v-else-if="displayMode === 'binary'" :class="$style.dataDisplay">
<div v-for="(binaryDataEntry, index) in binaryData" :key="index">
<div :class="$style.binaryIndex" v-if="binaryData.length > 1">
<div>
{{ index + 1 }}
</div>
</div>
<div :class="$style.binaryRow">
<div
:class="$style.binaryCell"
v-for="(binaryData, key) in binaryDataEntry"
:key="index + '_' + key"
>
<div>
<div :class="$style.binaryHeader">
{{ key }}
</div>
<div v-if="binaryData.fileName">
<div>
<n8n-text size="small" :bold="true"
>{{ $locale.baseText('runData.fileName') }}:
</n8n-text>
</div>
<div :class="$style.binaryValue">{{ binaryData.fileName }}</div>
</div>
<div v-if="binaryData.directory">
<div>
<n8n-text size="small" :bold="true"
>{{ $locale.baseText('runData.directory') }}:
</n8n-text>
</div>
<div :class="$style.binaryValue">{{ binaryData.directory }}</div>
</div>
<div v-if="binaryData.fileExtension">
<div>
<n8n-text size="small" :bold="true"
>{{ $locale.baseText('runData.fileExtension') }}:</n8n-text
>
</div>
<div :class="$style.binaryValue">{{ binaryData.fileExtension }}</div>
</div>
<div v-if="binaryData.mimeType">
<div>
<n8n-text size="small" :bold="true"
>{{ $locale.baseText('runData.mimeType') }}:
</n8n-text>
</div>
<div :class="$style.binaryValue">{{ binaryData.mimeType }}</div>
</div>
<div v-if="binaryData.fileSize">
<div>
<n8n-text size="small" :bold="true"
>{{ $locale.baseText('runData.fileSize') }}:
</n8n-text>
</div>
<div :class="$style.binaryValue">{{ binaryData.fileSize }}</div>
</div>
<div :class="$style.binaryButtonContainer">
<n8n-button
v-if="isViewable(index, key)"
size="small"
:label="$locale.baseText('runData.showBinaryData')"
class="binary-data-show-data-button"
@click="displayBinaryData(index, key)"
/>
<n8n-button
v-if="isDownloadable(index, key)"
size="small"
type="secondary"
:label="$locale.baseText('runData.downloadBinaryData')"
class="binary-data-show-data-button"
@click="downloadBinaryData(index, key)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
:class="$style.pagination"
v-if="hasNodeRun && !hasRunError && dataCount > pageSize"
v-show="!editMode.enabled"
>
<el-pagination
background
:hide-on-single-page="true"
:current-page.sync="currentPage"
:pager-count="5"
:page-size="pageSize"
layout="prev, pager, next"
@current-change="onCurrentPageChange"
:total="dataCount"
>
</el-pagination>
<div :class="$style.pageSizeSelector">
<n8n-select size="mini" :value="pageSize" @input="onPageSizeChange" popper-append-to-body>
<template #prepend>{{ $locale.baseText('ndv.output.pageSize') }}</template>
<n8n-option v-for="size in pageSizes" :key="size" :label="size" :value="size">
</n8n-option>
<n8n-option :label="$locale.baseText('ndv.output.all')" :value="dataCount"> </n8n-option>
</n8n-select>
</div>
</div>
<n8n-block-ui :show="blockUI" :class="$style.uiBlocker" />
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { saveAs } from 'file-saver';
import {
IBinaryData,
IBinaryKeyData,
IDataObject,
INodeExecutionData,
INodeTypeDescription,
IRunData,
IRunExecutionData,
} from 'n8n-workflow';
import {
IExecutionResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IRunDataDisplayMode,
ITab,
NodePanelType,
} from '@/Interface';
import {
DATA_PINNING_DOCS_URL,
DATA_EDITING_DOCS_URL,
LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG,
LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG,
MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_ITEMS_AUTO_ALL,
TEST_PIN_DATA,
HTML_NODE_TYPE,
} from '@/constants';
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
import WarningTooltip from '@/components/WarningTooltip.vue';
import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { externalHooks } from '@/mixins/externalHooks';
import { genericHelpers } from '@/mixins/genericHelpers';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { pinData } from '@/mixins/pinData';
import { CodeEditor } from '@/components/forms';
import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
import { clearJsonKey, executionDataToJson, stringSizeInBytes } from '@/utils';
import { isEmpty } from '@/utils';
import { useWorkflowsStore } from '@/stores/workflows';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
const RunDataTable = () => import('@/components/RunDataTable.vue');
const RunDataJson = () => import('@/components/RunDataJson.vue');
const RunDataSchema = () => import('@/components/RunDataSchema.vue');
const RunDataHtml = () => import('@/components/RunDataHtml.vue');
export type EnterEditModeArgs = {
origin: 'editIconButton' | 'insertTestDataLink';
};
export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).extend({
name: 'RunData',
components: {
BinaryDataDisplay,
NodeErrorView,
WarningTooltip,
CodeEditor,
RunDataTable,
RunDataJson,
RunDataSchema,
RunDataHtml,
},
props: {
nodeUi: {
type: Object as PropType<INodeUi>,
},
runIndex: {
type: Number,
},
linkedRuns: {
type: Boolean,
},
canLinkRuns: {
type: Boolean,
},
tooMuchDataTitle: {
type: String,
},
noDataInBranchMessage: {
type: String,
},
isExecuting: {
type: Boolean,
},
executingMessage: {
type: String,
},
sessionId: {
type: String,
},
paneType: {
type: String as PropType<NodePanelType>,
},
overrideOutputs: {
type: Array as PropType<number[]>,
},
mappingEnabled: {
type: Boolean,
},
distanceFromActive: {
type: Number,
},
blockUI: {
type: Boolean,
default: false,
},
isProductionExecutionPreview: {
type: Boolean,
default: false,
},
},
data() {
return {
binaryDataPreviewActive: false,
dataSize: 0,
showData: false,
outputIndex: 0,
binaryDataDisplayVisible: false,
binaryDataDisplayData: null as IBinaryData | null,
MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_ITEMS_AUTO_ALL,
currentPage: 1,
pageSize: 10,
pageSizes: [10, 25, 50, 100],
eventBus: dataPinningEventBus,
pinDataDiscoveryTooltipVisible: false,
isControlledPinDataTooltip: false,
};
},
mounted() {
this.init();
if (!this.isPaneTypeInput) {
this.eventBus.$on('data-pinning-error', this.onDataPinningError);
this.eventBus.$on('data-unpinning', this.onDataUnpinning);
this.showPinDataDiscoveryTooltip(this.jsonData);
}
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex: this.currentOutputIndex,
});
if (this.paneType === 'output') this.setDisplayMode();
},
destroyed() {
this.hidePinDataDiscoveryTooltip();
this.eventBus.$off('data-pinning-error', this.onDataPinningError);
this.eventBus.$off('data-unpinning', this.onDataUnpinning);
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore),
activeNode(): INodeUi | null {
return this.ndvStore.activeNode;
},
dataPinningDocsUrl(): string {
return DATA_PINNING_DOCS_URL;
},
dataEditingDocsUrl(): string {
return DATA_EDITING_DOCS_URL;
},
displayMode(): IRunDataDisplayMode {
return this.ndvStore.getPanelDisplayMode(this.paneType);
},
node(): INodeUi | null {
return (this.nodeUi as INodeUi | null) || null;
},
nodeType(): INodeTypeDescription | null {
if (this.node) {
return this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
}
return null;
},
isTriggerNode(): boolean {
return this.nodeTypesStore.isTriggerNode(this.node.type);
},
canPinData(): boolean {
return (
!this.isPaneTypeInput &&
this.isPinDataNodeType &&
!(this.binaryData && this.binaryData.length > 0)
);
},
buttons(): Array<{ label: string; value: string }> {
const defaults = [
{ label: this.$locale.baseText('runData.table'), value: 'table' },
{ label: this.$locale.baseText('runData.json'), value: 'json' },
];
if (this.binaryData.length) {
defaults.push({ label: this.$locale.baseText('runData.binary'), value: 'binary' });
}
const schemaView = { label: this.$locale.baseText('runData.schema'), value: 'schema' };
if (this.isPaneTypeInput) {
defaults.unshift(schemaView);
} else {
defaults.push(schemaView);
}
if (
this.isPaneTypeOutput &&
this.activeNode?.type === HTML_NODE_TYPE &&
this.activeNode.parameters.operation === 'generateHtmlTemplate'
) {
defaults.unshift({ label: 'HTML', value: 'html' });
}
return defaults;
},
hasNodeRun(): boolean {
return Boolean(
!this.isExecuting &&
this.node &&
((this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name)) ||
this.hasPinData),
);
},
isArtificalRecoveredEventItem(): boolean {
return this.inputData?.[0]?.json?.isArtificalRecoveredEventItem !== undefined ?? false;
},
subworkflowExecutionError(): Error | null {
return this.workflowsStore.subWorkflowExecutionError;
},
hasSubworkflowExecutionError(): boolean {
return Boolean(this.subworkflowExecutionError);
},
hasRunError(): boolean {
return Boolean(
this.node &&
this.workflowRunData &&
this.workflowRunData[this.node.name] &&
this.workflowRunData[this.node.name][this.runIndex] &&
this.workflowRunData[this.node.name][this.runIndex].error,
);
},
workflowExecution(): IExecutionResponse | null {
return this.workflowsStore.getWorkflowExecution;
},
workflowRunData(): IRunData | null {
if (this.workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData | undefined = this.workflowExecution.data;
if (executionData && executionData.resultData) {
return executionData.resultData.runData;
}
return null;
},
dataCount(): number {
return this.getDataCount(this.runIndex, this.currentOutputIndex);
},
dataSizeInMB(): string {
return (this.dataSize / 1024 / 1000).toLocaleString();
},
maxOutputIndex(): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length < this.runIndex) {
return 0;
}
if (runData[this.node.name][this.runIndex]) {
const taskData = runData[this.node.name][this.runIndex].data;
if (taskData && taskData.main) {
return taskData.main.length - 1;
}
}
return 0;
},
currentPageOffset(): number {
return this.pageSize * (this.currentPage - 1);
},
maxRunIndex(): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length) {
return runData[this.node.name].length - 1;
}
return 0;
},
rawInputData(): INodeExecutionData[] {
let inputData: INodeExecutionData[] = [];
if (this.node) {
inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
}
if (inputData.length === 0 || !Array.isArray(inputData)) {
return [];
}
return inputData;
},
inputData(): INodeExecutionData[] {
let inputData = this.rawInputData;
if (this.node && this.pinData && !this.isProductionExecutionPreview) {
inputData = Array.isArray(this.pinData)
? this.pinData.map((value) => ({
json: value,
}))
: [
{
json: this.pinData,
},
];
}
const offset = this.pageSize * (this.currentPage - 1);
inputData = inputData.slice(offset, offset + this.pageSize);
return inputData;
},
jsonData(): IDataObject[] {
return executionDataToJson(this.inputData);
},
binaryData(): IBinaryKeyData[] {
if (!this.node) {
return [];
}
const binaryData = this.getBinaryData(
this.workflowRunData,
this.node.name,
this.runIndex,
this.currentOutputIndex,
);
return binaryData.filter((data) => Boolean(data && Object.keys(data).length));
},
currentOutputIndex(): number {
if (
this.overrideOutputs &&
this.overrideOutputs.length &&
!this.overrideOutputs.includes(this.outputIndex)
) {
return this.overrideOutputs[0] as number;
}
return this.outputIndex;
},
branches(): ITab[] {
function capitalize(name: string) {
return name.charAt(0).toLocaleUpperCase() + name.slice(1);
}
const branches: ITab[] = [];
for (let i = 0; i <= this.maxOutputIndex; i++) {
if (this.overrideOutputs && !this.overrideOutputs.includes(i)) {
continue;
}
const itemsCount = this.getDataCount(this.runIndex, i);
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount });
let outputName = this.getOutputName(i);
if (`${outputName}` === `${i}`) {
outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`;
} else {
outputName = capitalize(
`${this.getOutputName(i)} ${this.$locale.baseText('ndv.output.branch')}`,
);
}
branches.push({
label: itemsCount ? `${outputName} (${itemsCount} ${items})` : outputName,
value: i,
});
}
return branches;
},
editMode(): { enabled: boolean; value: string } {
return this.isPaneTypeInput
? { enabled: false, value: '' }
: this.ndvStore.outputPanelEditMode;
},
isPaneTypeInput(): boolean {
return this.paneType === 'input';
},
isPaneTypeOutput(): boolean {
return this.paneType === 'output';
},
},
methods: {
onItemHover(itemIndex: number | null) {
if (itemIndex === null) {
this.$emit('itemHover', null);
return;
}
this.$emit('itemHover', {
outputIndex: this.currentOutputIndex,
itemIndex,
});
},
onClickDataPinningDocsLink() {
this.$telemetry.track('User clicked ndv link', {
workflow_id: this.workflowsStore.workflowId,
session_id: this.sessionId,
node_type: this.activeNode?.type,
pane: 'output',
type: 'data-pinning-docs',
});
},
showPinDataDiscoveryTooltip(value: IDataObject[]) {
if (!this.isTriggerNode) {
return;
}
if (
value &&
value.length > 0 &&
!this.isReadOnly &&
!localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG)
) {
this.pinDataDiscoveryComplete();
setTimeout(() => {
this.isControlledPinDataTooltip = true;
this.pinDataDiscoveryTooltipVisible = true;
this.eventBus.$emit('data-pinning-discovery', { isTooltipVisible: true });
}, 500); // Wait for NDV to open
}
},
hidePinDataDiscoveryTooltip() {
if (this.pinDataDiscoveryTooltipVisible) {
this.isControlledPinDataTooltip = false;
this.pinDataDiscoveryTooltipVisible = false;
this.eventBus.$emit('data-pinning-discovery', { isTooltipVisible: false });
}
},
pinDataDiscoveryComplete() {
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG, 'true');
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true');
},
enterEditMode({ origin }: EnterEditModeArgs) {
const inputData = this.pinData
? clearJsonKey(this.pinData)
: executionDataToJson(this.rawInputData);
const data = inputData.length > 0 ? inputData : TEST_PIN_DATA;
this.ndvStore.setOutputPanelEditModeEnabled(true);
this.ndvStore.setOutputPanelEditModeValue(JSON.stringify(data, null, 2));
this.$telemetry.track('User opened ndv edit state', {
node_type: this.activeNode?.type,
click_type: origin === 'editIconButton' ? 'button' : 'link',
session_id: this.sessionId,
run_index: this.runIndex,
is_output_present: this.hasNodeRun || this.hasPinData,
view: !this.hasNodeRun && !this.hasPinData ? 'undefined' : this.displayMode,
is_data_pinned: this.hasPinData,
});
},
onClickCancelEdit() {
this.ndvStore.setOutputPanelEditModeEnabled(false);
this.ndvStore.setOutputPanelEditModeValue('');
this.onExitEditMode({ type: 'cancel' });
},
onClickSaveEdit() {
const { value } = this.editMode;
this.clearAllStickyNotifications();
if (!this.isValidPinDataSize(value)) {
this.onDataPinningError({ errorType: 'data-too-large', source: 'save-edit' });
return;
}
if (!this.isValidPinDataJSON(value)) {
this.onDataPinningError({ errorType: 'invalid-json', source: 'save-edit' });
return;
}
this.ndvStore.setOutputPanelEditModeEnabled(false);
this.workflowsStore.pinData({
node: this.node,
data: clearJsonKey(value) as INodeExecutionData[],
});
this.onDataPinningSuccess({ source: 'save-edit' });
this.onExitEditMode({ type: 'save' });
},
onExitEditMode({ type }: { type: 'save' | 'cancel' }) {
this.$telemetry.track('User closed ndv edit state', {
node_type: this.activeNode?.type,
session_id: this.sessionId,
run_index: this.runIndex,
view: this.displayMode,
type,
});
},
onDataUnpinning({
source,
}: {
source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal';
}) {
this.$telemetry.track('User unpinned ndv data', {
node_type: this.activeNode?.type,
session_id: this.sessionId,
run_index: this.runIndex,
source,
data_size: stringSizeInBytes(this.pinData),
});
},
onDataPinningSuccess({ source }: { source: 'pin-icon-click' | 'save-edit' }) {
const telemetryPayload = {
pinning_source: source,
node_type: this.activeNode.type,
session_id: this.sessionId,
data_size: stringSizeInBytes(this.pinData),
view: this.displayMode,
run_index: this.runIndex,
};
this.$externalHooks().run('runData.onDataPinningSuccess', telemetryPayload);
this.$telemetry.track('Ndv data pinning success', telemetryPayload);
},
onDataPinningError({
errorType,
source,
}: {
errorType: 'data-too-large' | 'invalid-json';
source: 'on-ndv-close-modal' | 'pin-icon-click' | 'save-edit';
}) {
this.$telemetry.track('Ndv data pinning failure', {
pinning_source: source,
node_type: this.activeNode.type,
session_id: this.sessionId,
data_size: stringSizeInBytes(this.pinData),
view: this.displayMode,
run_index: this.runIndex,
error_type: errorType,
});
},
async onTogglePinData({
source,
}: {
source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal';
}) {
if (source === 'pin-icon-click') {
const telemetryPayload = {
node_type: this.activeNode.type,
session_id: this.sessionId,
run_index: this.runIndex,
view: !this.hasNodeRun && !this.hasPinData ? 'none' : this.displayMode,
};
this.$externalHooks().run('runData.onTogglePinData', telemetryPayload);
this.$telemetry.track('User clicked pin data icon', telemetryPayload);
}
this.updateNodeParameterIssues(this.node);
if (this.hasPinData) {
this.onDataUnpinning({ source });
this.workflowsStore.unpinData({ node: this.node });
return;
}
const data = executionDataToJson(this.rawInputData) as INodeExecutionData[];
if (!this.isValidPinDataSize(data)) {
this.onDataPinningError({ errorType: 'data-too-large', source: 'pin-icon-click' });
return;
}
this.onDataPinningSuccess({ source: 'pin-icon-click' });
this.workflowsStore.pinData({ node: this.node, data });
if (this.maxRunIndex > 0) {
this.$showToast({
title: this.$locale.baseText('ndv.pinData.pin.multipleRuns.title', {
interpolate: {
index: `${this.runIndex}`,
},
}),
message: this.$locale.baseText('ndv.pinData.pin.multipleRuns.description'),
type: 'success',
duration: 2000,
});
}
this.hidePinDataDiscoveryTooltip();
this.pinDataDiscoveryComplete();
},
switchToBinary() {
this.onDisplayModeChange('binary');
},
onBranchChange(value: number) {
this.outputIndex = value;
this.$telemetry.track('User changed ndv branch', {
session_id: this.sessionId,
branch_index: value,
node_type: this.activeNode.type,
node_type_input_selection: this.nodeType ? this.nodeType.name : '',
pane: this.paneType,
});
},
showTooMuchData() {
this.showData = true;
this.$telemetry.track('User clicked ndv button', {
node_type: this.activeNode.type,
workflow_id: this.workflowsStore.workflowId,
session_id: this.sessionId,
pane: this.paneType,
type: 'showTooMuchData',
});
},
linkRun() {
this.$emit('linkRun');
},
unlinkRun() {
this.$emit('unlinkRun');
},
onCurrentPageChange() {
this.$telemetry.track('User changed ndv page', {
node_type: this.activeNode?.type,
workflow_id: this.workflowsStore.workflowId,
session_id: this.sessionId,
pane: this.paneType,
page_selected: this.currentPage,
page_size: this.pageSize,
items_total: this.dataCount,
});
},
onPageSizeChange(pageSize: number) {
this.pageSize = pageSize;
const maxPage = Math.ceil(this.dataCount / this.pageSize);
if (maxPage < this.currentPage) {
this.currentPage = maxPage;
}
this.$telemetry.track('User changed ndv page size', {
node_type: this.activeNode?.type,
workflow_id: this.workflowsStore.workflowId,
session_id: this.sessionId,
pane: this.paneType,
page_selected: this.currentPage,
page_size: this.pageSize,
items_total: this.dataCount,
});
},
onDisplayModeChange(displayMode: IRunDataDisplayMode) {
const previous = this.displayMode;
this.ndvStore.setPanelDisplayMode({ pane: this.paneType, mode: displayMode });
const dataContainer = this.$refs.dataContainer;
if (dataContainer) {
const dataDisplay = (dataContainer as Element).children[0];
if (dataDisplay) {
dataDisplay.scrollTo(0, 0);
}
}
this.closeBinaryDataDisplay();
this.$externalHooks().run('runData.displayModeChanged', {
newValue: displayMode,
oldValue: previous,
});
if (this.activeNode) {
this.$telemetry.track('User changed ndv item view', {
previous_view: previous,
new_view: displayMode,
node_type: this.activeNode.type,
workflow_id: this.workflowsStore.workflowId,
session_id: this.sessionId,
pane: this.paneType,
});
}
},
getRunLabel(option: number) {
let itemsCount = 0;
for (let i = 0; i <= this.maxOutputIndex; i++) {
itemsCount += this.getDataCount(option - 1, i);
}
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount });
const itemsLabel = itemsCount > 0 ? ` (${itemsCount} ${items})` : '';
return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex + 1) + itemsLabel;
},
getDataCount(runIndex: number, outputIndex: number) {
if (this.pinData) {
return this.pinData.length;
}
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length <= runIndex) {
return 0;
}
if (runData[this.node.name][runIndex].hasOwnProperty('error')) {
return 1;
}
if (
!runData[this.node.name][runIndex].hasOwnProperty('data') ||
runData[this.node.name][runIndex].data === undefined
) {
return 0;
}
const inputData = this.getMainInputData(runData[this.node.name][runIndex].data!, outputIndex);
return inputData.length;
},
init() {
// Reset the selected output index every time another node gets selected
this.outputIndex = 0;
this.refreshDataSize();
this.closeBinaryDataDisplay();
if (this.binaryData.length > 0) {
this.ndvStore.setPanelDisplayMode({
pane: this.paneType as 'input' | 'output',
mode: 'binary',
});
} else if (this.displayMode === 'binary') {
this.ndvStore.setPanelDisplayMode({
pane: this.paneType as 'input' | 'output',
mode: 'table',
});
}
},
closeBinaryDataDisplay() {
this.binaryDataDisplayVisible = false;
this.binaryDataDisplayData = null;
},
clearExecutionData() {
this.workflowsStore.setWorkflowExecutionData(null);
this.updateNodesExecutionIssues();
},
isViewable(index: number, key: string): boolean {
const { fileType } = this.binaryData[index][key];
return !!fileType && ['image', 'video', 'text', 'json'].includes(fileType);
},
isDownloadable(index: number, key: string): boolean {
const { mimeType, fileName } = this.binaryData[index][key];
return !!(mimeType && fileName);
},
async downloadBinaryData(index: number, key: string) {
const { id, data, fileName, fileExtension, mimeType } = this.binaryData[index][key];
if (id) {
const url = this.restApi().getBinaryUrl(id, 'download', fileName, mimeType);
saveAs(url, [fileName, fileExtension].join('.'));
return;
} else {
const bufferString = 'data:' + mimeType + ';base64,' + data;
const blob = await fetch(bufferString).then((d) => d.blob());
saveAs(blob, fileName);
}
},
displayBinaryData(index: number, key: string) {
this.binaryDataDisplayVisible = true;
this.binaryDataDisplayData = {
node: this.node!.name,
runIndex: this.runIndex,
outputIndex: this.currentOutputIndex,
index,
key,
};
},
getOutputName(outputIndex: number) {
if (this.node === null) {
return outputIndex + 1;
}
const nodeType = this.nodeType;
if (!nodeType || !nodeType.outputNames || nodeType.outputNames.length <= outputIndex) {
return outputIndex + 1;
}
return nodeType.outputNames[outputIndex];
},
refreshDataSize() {
// Hide by default the data from being displayed
this.showData = false;
// Check how much data there is to display
const inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
const offset = this.pageSize * (this.currentPage - 1);
const jsonItems = inputData.slice(offset, offset + this.pageSize).map((item) => item.json);
this.dataSize = JSON.stringify(jsonItems).length;
if (this.dataSize < this.MAX_DISPLAY_DATA_SIZE) {
// Data is reasonable small (< 200kb) so display it directly
this.showData = true;
}
},
onRunIndexChange(run: number) {
this.$emit('runChange', run);
},
enableNode() {
if (this.node) {
const updateInformation = {
name: this.node.name,
properties: {
disabled: !this.node.disabled,
} as IDataObject,
} as INodeUpdatePropertiesInformation;
this.workflowsStore.updateNodeProperties(updateInformation);
}
},
goToErroredNode() {
if (this.node) {
this.ndvStore.activeNodeName = this.node.name;
}
},
setDisplayMode() {
if (!this.activeNode) return;
const shouldDisplayHtml =
this.activeNode.type === HTML_NODE_TYPE &&
this.activeNode.parameters.operation === 'generateHtmlTemplate';
if (shouldDisplayHtml) {
this.ndvStore.setPanelDisplayMode({
pane: 'output',
mode: 'html',
});
}
},
},
watch: {
node() {
this.init();
},
hasNodeRun() {
if (this.paneType === 'output') this.setDisplayMode();
},
inputData: {
handler(data: INodeExecutionData[]) {
if (this.paneType && data) {
this.ndvStore.setNDVPanelDataIsEmpty({
panel: this.paneType as 'input' | 'output',
isEmpty: data.every((item) => isEmpty(item.json)),
});
}
},
immediate: true,
deep: true,
},
jsonData(value: IDataObject[]) {
this.refreshDataSize();
this.showPinDataDiscoveryTooltip(value);
},
binaryData(newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
this.switchToBinary();
} else if (!newData.length && this.displayMode === 'binary') {
this.onDisplayModeChange('table');
}
},
currentOutputIndex(branchIndex: number) {
this.ndvStore.setNDVBranchIndex({
pane: this.paneType as 'input' | 'output',
branchIndex,
});
},
},
});
</script>
<style lang="scss" module>
.infoIcon {
color: var(--color-foreground-dark);
}
.center {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
text-align: center;
> * {
max-width: 316px;
margin-bottom: var(--spacing-2xs);
}
}
.container {
position: relative;
width: 100%;
height: 100%;
background-color: var(--color-background-base);
display: flex;
flex-direction: column;
}
.pinnedDataCallout {
border-radius: inherit;
border-bottom-right-radius: 0;
border-top: 0;
border-left: 0;
border-right: 0;
}
.header {
display: flex;
align-items: center;
margin-bottom: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-s) 0 var(--spacing-s);
position: relative;
overflow-x: auto;
overflow-y: hidden;
min-height: calc(30px + var(--spacing-s));
> *:first-child {
flex-grow: 1;
}
}
.dataContainer {
position: relative;
height: 100%;
&:hover {
.actions-group {
opacity: 1;
}
}
}
.dataDisplay {
position: absolute;
top: 0;
left: 0;
padding: 0 var(--spacing-s) var(--spacing-3xl) var(--spacing-s);
right: 0;
overflow-y: auto;
line-height: var(--font-line-height-xloose);
word-break: normal;
height: 100%;
}
.tabs {
margin-bottom: var(--spacing-s);
}
.itemsCount {
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s);
}
.runSelector {
max-width: 210px;
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s);
display: flex;
align-items: center;
> * {
margin-right: var(--spacing-4xs);
}
}
.pagination {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
bottom: 0;
padding: 5px;
overflow: auto;
}
.pageSizeSelector {
text-transform: capitalize;
max-width: 150px;
flex: 0 1 auto;
}
.binaryIndex {
display: block;
padding: var(--spacing-2xs);
font-size: var(--font-size-2xs);
> * {
display: inline-block;
width: 30px;
height: 30px;
line-height: 30px;
border-radius: var(--border-radius-base);
text-align: center;
background-color: var(--color-foreground-xdark);
font-weight: var(--font-weight-bold);
color: var(--color-text-xlight);
}
}
.binaryRow {
display: inline-flex;
font-size: var(--font-size-2xs);
}
.binaryCell {
display: inline-block;
width: 300px;
overflow: hidden;
background-color: var(--color-foreground-xlight);
margin-right: var(--spacing-s);
margin-bottom: var(--spacing-s);
border-radius: var(--border-radius-base);
border: var(--border-base);
padding: var(--spacing-s);
}
.binaryHeader {
color: $color-primary;
font-weight: var(--font-weight-bold);
font-size: 1.2em;
padding-bottom: var(--spacing-2xs);
margin-bottom: var(--spacing-2xs);
border-bottom: 1px solid var(--color-text-light);
}
.binaryButtonContainer {
margin-top: 1.5em;
display: flex;
flex-direction: row;
justify-content: center;
> * {
flex-grow: 0;
margin-right: var(--spacing-3xs);
}
}
.binaryValue {
white-space: initial;
word-wrap: break-word;
}
.displayModes {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.tooltipContain {
max-width: 240px;
}
.pinDataButton {
svg {
transition: transform 0.3s ease;
}
}
.spinner {
* {
color: var(--color-primary);
min-height: 40px;
min-width: 40px;
}
display: flex;
justify-content: center;
margin-bottom: var(--spacing-s);
}
.editMode {
height: calc(100% - var(--spacing-s));
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
.editModeBody {
flex: 1 1 auto;
width: 100%;
height: 100%;
overflow: hidden;
}
.editModeFooter {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-s);
}
.editModeFooterInfotip {
display: flex;
flex: 1;
width: 100%;
}
.editModeActions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing-s);
}
.stretchVertically {
height: 100%;
}
.uiBlocker {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>