feat: Run once for each item tooltip (#9486)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Michael Kret 2024-06-04 10:18:17 +03:00 committed by GitHub
parent 631f077c18
commit b91e50fc92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 142 additions and 5 deletions

View file

@ -114,6 +114,7 @@
:read-only="readOnly" :read-only="readOnly"
:block-u-i="blockUi && showTriggerPanel" :block-u-i="blockUi && showTriggerPanel"
:executable="!readOnly" :executable="!readOnly"
:input-size="inputSize"
@value-changed="valueChanged" @value-changed="valueChanged"
@execute="onNodeExecute" @execute="onNodeExecute"
@stop-execution="onStopExecution" @stop-execution="onStopExecution"
@ -313,6 +314,8 @@ export default defineComponent({
return null; return null;
}); });
const inputSize = computed(() => ndvStore.ndvInputDataWithPinnedData.length);
const isTriggerNode = computed( const isTriggerNode = computed(
() => () =>
!!activeNodeType.value && !!activeNodeType.value &&
@ -848,6 +851,7 @@ export default defineComponent({
inputRun, inputRun,
linked, linked,
inputNodeName, inputNodeName,
inputSize,
hasForeignCredential, hasForeignCredential,
outputRun, outputRun,
isOutputPaneActive, isOutputPaneActive,

View file

@ -1,20 +1,25 @@
<template> <template>
<div> <div>
<n8n-tooltip placement="bottom" :disabled="!disabledHint"> <n8n-tooltip placement="right" :disabled="!tooltipText">
<template #content> <template #content>
<div>{{ disabledHint }}</div> <div>{{ tooltipText }}</div>
</template> </template>
<div> <div>
<n8n-button <n8n-button
v-bind="$attrs" v-bind="$attrs"
:loading="nodeRunning && !isListeningForEvents && !isListeningForWorkflowEvents" :loading
:disabled="disabled || !!disabledHint" :disabled="disabled || !!disabledHint"
:label="buttonLabel" :label="buttonLabel"
:type="type" :type="type"
:size="size" :size="size"
:icon="!isListeningForEvents && !hideIcon ? 'flask' : undefined" :icon="!isListeningForEvents && !hideIcon ? 'flask' : undefined"
:transparent-background="transparent" :transparent-background="transparent"
:title="!isTriggerNode ? $locale.baseText('ndv.execute.testNode.description') : ''" :title="
!isTriggerNode && !tooltipText
? $locale.baseText('ndv.execute.testNode.description')
: ''
"
@mouseover="onMouseOver"
@click="onClick" @click="onClick"
/> />
</div> </div>
@ -46,6 +51,10 @@ import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
const MAX_POPUP_COUNT = 10;
const POPUP_UPDATE_DELAY = 3000;
export default defineComponent({ export default defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: { props: {
@ -76,6 +85,9 @@ export default defineComponent({
hideIcon: { hideIcon: {
type: Boolean, type: Boolean,
}, },
tooltip: {
type: String,
},
}, },
emits: ['stopExecution', 'execute'], emits: ['stopExecution', 'execute'],
setup(props) { setup(props) {
@ -91,6 +103,7 @@ export default defineComponent({
pinnedData, pinnedData,
runWorkflow, runWorkflow,
stopCurrentExecution, stopCurrentExecution,
lastPopupCountUpdate: 0,
...useToast(), ...useToast(),
...useMessage(), ...useMessage(),
}; };
@ -193,6 +206,12 @@ export default defineComponent({
return ''; return '';
}, },
tooltipText(): string {
if (this.disabledHint) return this.disabledHint;
if (this.tooltip && !this.loading && this.testStepButtonPopupCount() < MAX_POPUP_COUNT)
return this.tooltip;
return '';
},
buttonLabel(): string { buttonLabel(): string {
if (this.isListeningForEvents || this.isListeningForWorkflowEvents) { if (this.isListeningForEvents || this.isListeningForWorkflowEvents) {
return this.$locale.baseText('ndv.execute.stopListening'); return this.$locale.baseText('ndv.execute.stopListening');
@ -220,6 +239,10 @@ export default defineComponent({
return this.$locale.baseText('ndv.execute.testNode'); return this.$locale.baseText('ndv.execute.testNode');
}, },
loading(): boolean {
return this.nodeRunning && !this.isListeningForEvents && !this.isListeningForWorkflowEvents;
},
}, },
methods: { methods: {
async stopWaitingForWebhook() { async stopWaitingForWebhook() {
@ -231,6 +254,22 @@ export default defineComponent({
} }
}, },
testStepButtonPopupCount() {
return Number(localStorage.getItem(NODE_TEST_STEP_POPUP_COUNT_KEY));
},
onMouseOver() {
const count = this.testStepButtonPopupCount();
if (count < MAX_POPUP_COUNT && !this.disabledHint && this.tooltipText) {
const now = Date.now();
if (!this.lastPopupCountUpdate || now - this.lastPopupCountUpdate >= POPUP_UPDATE_DELAY) {
localStorage.setItem(NODE_TEST_STEP_POPUP_COUNT_KEY, `${count + 1}`);
this.lastPopupCountUpdate = now;
}
}
},
async onClick() { async onClick() {
// Show chat if it's a chat node or a child of a chat node with no input data // Show chat if it's a chat node or a child of a chat node with no input data
if (this.isChatNode || (this.isChatChild && this.ndvStore.isNDVDataEmpty('input'))) { if (this.isChatNode || (this.isChatChild && this.ndvStore.isNDVDataEmpty('input'))) {

View file

@ -22,6 +22,7 @@
data-test-id="node-execute-button" data-test-id="node-execute-button"
:node-name="node.name" :node-name="node.name"
:disabled="outputPanelEditMode.enabled && !isTriggerNode" :disabled="outputPanelEditMode.enabled && !isTriggerNode"
:tooltip="executeButtonTooltip"
size="small" size="small"
telemetry-source="parameters" telemetry-source="parameters"
@execute="onNodeExecute" @execute="onNodeExecute"
@ -307,6 +308,19 @@ export default defineComponent({
isLatestNodeVersion(): boolean { isLatestNodeVersion(): boolean {
return !this.node?.typeVersion || this.latestVersion === this.node.typeVersion; return !this.node?.typeVersion || this.latestVersion === this.node.typeVersion;
}, },
executeButtonTooltip(): string {
if (
this.node &&
this.isLatestNodeVersion &&
this.inputSize > 1 &&
!NodeHelpers.isSingleExecution(this.node.type, this.node.parameters)
) {
return this.$locale.baseText('nodeSettings.executeButtonTooltip.times', {
interpolate: { inputSize: this.inputSize },
});
}
return '';
},
nodeVersionTag(): string { nodeVersionTag(): string {
if (!this.nodeType || this.nodeType.hidden) { if (!this.nodeType || this.nodeType.hidden) {
return this.$locale.baseText('nodeSettings.deprecated'); return this.$locale.baseText('nodeSettings.deprecated');
@ -422,6 +436,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
inputSize: {
type: Number,
default: 0,
},
}, },
data() { data() {
return { return {

View file

@ -1149,6 +1149,7 @@
"nodeSettings.onError.options.stopWorkflow.description": "Halt execution and fail workflow", "nodeSettings.onError.options.stopWorkflow.description": "Halt execution and fail workflow",
"nodeSettings.onError.options.stopWorkflow.displayName": "Stop Workflow", "nodeSettings.onError.options.stopWorkflow.displayName": "Stop Workflow",
"nodeSettings.docs": "Docs", "nodeSettings.docs": "Docs",
"nodeSettings.executeButtonTooltip.times": "Will execute {inputSize} times, once for each input item",
"nodeSettings.executeOnce.description": "If active, the node executes only once, with data from the first item it receives", "nodeSettings.executeOnce.description": "If active, the node executes only once, with data from the first item it receives",
"nodeSettings.executeOnce.displayName": "Execute Once", "nodeSettings.executeOnce.displayName": "Execute Once",
"nodeSettings.maxTries.description": "Number of times to attempt to execute the node before failing the execution", "nodeSettings.maxTries.description": "Number of times to attempt to execute the node before failing the execution",

View file

@ -1,3 +1,5 @@
import type { NodeParameterValue } from './Interfaces';
export const BINARY_ENCODING = 'base64'; export const BINARY_ENCODING = 'base64';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z'; export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
@ -62,3 +64,32 @@ export const LANGCHAIN_CUSTOM_TOOLS = [
CODE_TOOL_LANGCHAIN_NODE_TYPE, CODE_TOOL_LANGCHAIN_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
]; ];
//nodes that would execute only once with such parameters
//add 'undefined' to parameters values if it is parameter's default value
export const SINGLE_EXECUTION_NODES: { [key: string]: { [key: string]: NodeParameterValue[] } } = {
'n8n-nodes-base.code': {
mode: [undefined, 'runOnceForAllItems'],
},
'n8n-nodes-base.executeWorkflow': {
mode: [undefined, 'once'],
},
'n8n-nodes-base.crateDb': {
operation: [undefined, 'update'], // default insert
},
'n8n-nodes-base.timescaleDb': {
operation: [undefined, 'update'], // default insert
},
'n8n-nodes-base.microsoftSql': {
operation: [undefined, 'update', 'delete'], // default insert
},
'n8n-nodes-base.questDb': {
operation: [undefined], // default insert
},
'n8n-nodes-base.mongoDb': {
operation: ['insert', 'update'],
},
'n8n-nodes-base.redis': {
operation: [undefined], // default info
},
};

View file

@ -56,6 +56,7 @@ import type { Workflow } from './Workflow';
import { validateFilterParameter } from './NodeParameters/FilterParameter'; import { validateFilterParameter } from './NodeParameters/FilterParameter';
import { validateFieldType } from './TypeValidation'; import { validateFieldType } from './TypeValidation';
import { ApplicationError } from './errors/application.error'; import { ApplicationError } from './errors/application.error';
import { SINGLE_EXECUTION_NODES } from './Constants';
export const cronNodeOptions: INodePropertyCollection[] = [ export const cronNodeOptions: INodePropertyCollection[] = [
{ {
@ -1748,3 +1749,19 @@ export function getCredentialsForNode(
return object.description.credentials ?? []; return object.description.credentials ?? [];
} }
export function isSingleExecution(type: string, parameters: INodeParameters): boolean {
const singleExecutionCase = SINGLE_EXECUTION_NODES[type];
if (singleExecutionCase) {
for (const parameter of Object.keys(singleExecutionCase)) {
if (!singleExecutionCase[parameter].includes(parameters[parameter] as NodeParameterValue)) {
return false;
}
}
return true;
}
return false;
}

View file

@ -1,7 +1,7 @@
import type { INode, INodeParameters, INodeProperties, INodeTypeDescription } from '@/Interfaces'; import type { INode, INodeParameters, INodeProperties, INodeTypeDescription } from '@/Interfaces';
import type { Workflow } from '../src'; import type { Workflow } from '../src';
import { getNodeParameters, getNodeHints } from '@/NodeHelpers'; import { getNodeParameters, getNodeHints, isSingleExecution } from '@/NodeHelpers';
describe('NodeHelpers', () => { describe('NodeHelpers', () => {
describe('getNodeParameters', () => { describe('getNodeParameters', () => {
@ -3528,4 +3528,31 @@ describe('NodeHelpers', () => {
expect(hints).toHaveLength(1); expect(hints).toHaveLength(1);
}); });
}); });
describe('isSingleExecution', () => {
test('should determine based on node parameters if it would be executed once', () => {
expect(isSingleExecution('n8n-nodes-base.code', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.code', { mode: 'runOnceForEachItem' })).toEqual(
false,
);
expect(isSingleExecution('n8n-nodes-base.executeWorkflow', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.executeWorkflow', { mode: 'each' })).toEqual(false);
expect(isSingleExecution('n8n-nodes-base.crateDb', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.crateDb', { operation: 'update' })).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.timescaleDb', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.timescaleDb', { operation: 'update' })).toEqual(
true,
);
expect(isSingleExecution('n8n-nodes-base.microsoftSql', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.microsoftSql', { operation: 'update' })).toEqual(
true,
);
expect(isSingleExecution('n8n-nodes-base.microsoftSql', { operation: 'delete' })).toEqual(
true,
);
expect(isSingleExecution('n8n-nodes-base.questDb', {})).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.mongoDb', { operation: 'insert' })).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.mongoDb', { operation: 'update' })).toEqual(true);
expect(isSingleExecution('n8n-nodes-base.redis', {})).toEqual(true);
});
});
}); });