mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
feat(core): Node hints(warnings) system (#8954)
This commit is contained in:
parent
4d2115c163
commit
da6088d0bb
|
@ -34,6 +34,7 @@ import type {
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
CloseFunction,
|
CloseFunction,
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
|
NodeExecutionHint,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
|
@ -41,6 +42,7 @@ import {
|
||||||
NodeHelpers,
|
NodeHelpers,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
|
NodeExecutionOutput,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
|
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
|
||||||
|
@ -807,6 +809,7 @@ export class WorkflowExecute {
|
||||||
// Variables which hold temporary data for each node-execution
|
// Variables which hold temporary data for each node-execution
|
||||||
let executionData: IExecuteData;
|
let executionData: IExecuteData;
|
||||||
let executionError: ExecutionBaseError | undefined;
|
let executionError: ExecutionBaseError | undefined;
|
||||||
|
let executionHints: NodeExecutionHint[] = [];
|
||||||
let executionNode: INode;
|
let executionNode: INode;
|
||||||
let nodeSuccessData: INodeExecutionData[][] | null | undefined;
|
let nodeSuccessData: INodeExecutionData[][] | null | undefined;
|
||||||
let runIndex: number;
|
let runIndex: number;
|
||||||
|
@ -828,7 +831,7 @@ export class WorkflowExecute {
|
||||||
let lastExecutionTry = '';
|
let lastExecutionTry = '';
|
||||||
let closeFunction: Promise<void> | undefined;
|
let closeFunction: Promise<void> | undefined;
|
||||||
|
|
||||||
return new PCancelable(async (resolve, reject, onCancel) => {
|
return new PCancelable(async (resolve, _reject, onCancel) => {
|
||||||
// Let as many nodes listen to the abort signal, without getting the MaxListenersExceededWarning
|
// Let as many nodes listen to the abort signal, without getting the MaxListenersExceededWarning
|
||||||
setMaxListeners(Infinity, this.abortController.signal);
|
setMaxListeners(Infinity, this.abortController.signal);
|
||||||
|
|
||||||
|
@ -896,6 +899,7 @@ export class WorkflowExecute {
|
||||||
|
|
||||||
nodeSuccessData = null;
|
nodeSuccessData = null;
|
||||||
executionError = undefined;
|
executionError = undefined;
|
||||||
|
executionHints = [];
|
||||||
executionData =
|
executionData =
|
||||||
this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData;
|
this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData;
|
||||||
executionNode = executionData.node;
|
executionNode = executionData.node;
|
||||||
|
@ -1059,8 +1063,16 @@ export class WorkflowExecute {
|
||||||
this.mode,
|
this.mode,
|
||||||
this.abortController.signal,
|
this.abortController.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
nodeSuccessData = runNodeData.data;
|
nodeSuccessData = runNodeData.data;
|
||||||
|
|
||||||
|
if (nodeSuccessData instanceof NodeExecutionOutput) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
const hints: NodeExecutionHint[] = nodeSuccessData.getHints();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
executionHints.push(...hints);
|
||||||
|
}
|
||||||
|
|
||||||
if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') {
|
if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') {
|
||||||
// If errorOutput is activated check all the output items for error data.
|
// If errorOutput is activated check all the output items for error data.
|
||||||
// If any is found, route them to the last output as that will be the
|
// If any is found, route them to the last output as that will be the
|
||||||
|
@ -1244,7 +1256,7 @@ export class WorkflowExecute {
|
||||||
if (!inputData) {
|
if (!inputData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputData.forEach((item, itemIndex) => {
|
inputData.forEach((_item, itemIndex) => {
|
||||||
pairedItem.push({
|
pairedItem.push({
|
||||||
item: itemIndex,
|
item: itemIndex,
|
||||||
input: inputIndex,
|
input: inputIndex,
|
||||||
|
@ -1296,6 +1308,7 @@ export class WorkflowExecute {
|
||||||
}
|
}
|
||||||
|
|
||||||
taskData = {
|
taskData = {
|
||||||
|
hints: executionHints,
|
||||||
startTime,
|
startTime,
|
||||||
executionTime: new Date().getTime() - startTime,
|
executionTime: new Date().getTime() - startTime,
|
||||||
source: !executionData.source ? [] : executionData.source.main,
|
source: !executionData.source ? [] : executionData.source.main,
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import type { IRun, WorkflowTestData } from 'n8n-workflow';
|
import type { IRun, WorkflowTestData } from 'n8n-workflow';
|
||||||
import { ApplicationError, createDeferredPromise, Workflow } from 'n8n-workflow';
|
import {
|
||||||
|
ApplicationError,
|
||||||
|
createDeferredPromise,
|
||||||
|
NodeExecutionOutput,
|
||||||
|
Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { WorkflowExecute } from '@/WorkflowExecute';
|
import { WorkflowExecute } from '@/WorkflowExecute';
|
||||||
|
|
||||||
import * as Helpers from './helpers';
|
import * as Helpers from './helpers';
|
||||||
|
@ -192,4 +197,16 @@ describe('WorkflowExecute', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WorkflowExecute, NodeExecutionOutput type test', () => {
|
||||||
|
//TODO Add more tests here when execution hints are added to some node types
|
||||||
|
const nodeExecutionOutput = new NodeExecutionOutput(
|
||||||
|
[[{ json: { data: 123 } }]],
|
||||||
|
[{ message: 'TEXT HINT' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nodeExecutionOutput).toBeInstanceOf(NodeExecutionOutput);
|
||||||
|
expect(nodeExecutionOutput[0][0].json.data).toEqual(123);
|
||||||
|
expect(nodeExecutionOutput.getHints()[0].message).toEqual('TEXT HINT');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -152,6 +152,15 @@
|
||||||
</div>
|
</div>
|
||||||
<slot name="before-data" />
|
<slot name="before-data" />
|
||||||
|
|
||||||
|
<n8n-callout
|
||||||
|
v-for="hint in getNodeHints()"
|
||||||
|
:key="hint.message"
|
||||||
|
:class="$style.hintCallout"
|
||||||
|
:theme="hint.type || 'info'"
|
||||||
|
>
|
||||||
|
<n8n-text v-html="hint.message" size="small"></n8n-text>
|
||||||
|
</n8n-callout>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="maxOutputIndex > 0 && branches.length > 1"
|
v-if="maxOutputIndex > 0 && branches.length > 1"
|
||||||
:class="$style.tabs"
|
:class="$style.tabs"
|
||||||
|
@ -557,6 +566,7 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
IRunData,
|
IRunData,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
|
NodeHint,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -824,6 +834,15 @@ export default defineComponent({
|
||||||
hasRunError(): boolean {
|
hasRunError(): boolean {
|
||||||
return Boolean(this.node && this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error);
|
return Boolean(this.node && this.workflowRunData?.[this.node.name]?.[this.runIndex]?.error);
|
||||||
},
|
},
|
||||||
|
executionHints(): NodeHint[] {
|
||||||
|
if (this.hasNodeRun) {
|
||||||
|
const hints = this.node && this.workflowRunData?.[this.node.name]?.[this.runIndex]?.hints;
|
||||||
|
|
||||||
|
if (hints) return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
workflowExecution(): IExecutionResponse | null {
|
workflowExecution(): IExecutionResponse | null {
|
||||||
return this.workflowsStore.getWorkflowExecution;
|
return this.workflowsStore.getWorkflowExecution;
|
||||||
},
|
},
|
||||||
|
@ -1083,6 +1102,46 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
shouldHintBeDisplayed(hint: NodeHint): boolean {
|
||||||
|
const { location, whenToDisplay } = hint;
|
||||||
|
if (location) {
|
||||||
|
if (location === 'ndv') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (location === 'inputPane' && this.paneType === 'input') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location === 'outputPane' && this.paneType === 'output') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whenToDisplay === 'afterExecution' && !this.hasNodeRun) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whenToDisplay === 'beforeExecution' && this.hasNodeRun) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getNodeHints(): NodeHint[] {
|
||||||
|
if (this.node && this.nodeType) {
|
||||||
|
const workflow = this.workflowsStore.getCurrentWorkflow();
|
||||||
|
const workflowNode = workflow.getNode(this.node.name);
|
||||||
|
|
||||||
|
if (workflowNode) {
|
||||||
|
const executionHints = this.executionHints;
|
||||||
|
const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType);
|
||||||
|
return executionHints.concat(nodeHints).filter(this.shouldHintBeDisplayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
onItemHover(itemIndex: number | null) {
|
onItemHover(itemIndex: number | null) {
|
||||||
if (itemIndex === null) {
|
if (itemIndex === null) {
|
||||||
this.$emit('itemHover', null);
|
this.$emit('itemHover', null);
|
||||||
|
@ -1788,6 +1847,12 @@ export default defineComponent({
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hintCallout {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
margin-left: var(--spacing-s);
|
||||||
|
margin-right: var(--spacing-s);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
2
packages/nodes-base/utils/constants.ts
Normal file
2
packages/nodes-base/utils/constants.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const NODE_RAN_MULTIPLE_TIMES_WARNING =
|
||||||
|
"This node ran multiple times - once for each input item. You can change this by setting 'execute once' in the node settings. <a href='https://docs.n8n.io/flow-logic/looping/#executing-nodes-once' target='_blank'>More Info</a>";
|
|
@ -392,7 +392,7 @@ export interface IGetExecuteTriggerFunctions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRunNodeResponse {
|
export interface IRunNodeResponse {
|
||||||
data: INodeExecutionData[][] | null | undefined;
|
data: INodeExecutionData[][] | NodeExecutionOutput | null | undefined;
|
||||||
closeFunction?: CloseFunction;
|
closeFunction?: CloseFunction;
|
||||||
}
|
}
|
||||||
export interface IGetExecuteFunctions {
|
export interface IGetExecuteFunctions {
|
||||||
|
@ -1423,6 +1423,20 @@ export interface SupplyData {
|
||||||
closeFunction?: CloseFunction;
|
closeFunction?: CloseFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class NodeExecutionOutput extends Array {
|
||||||
|
private hints: NodeExecutionHint[];
|
||||||
|
|
||||||
|
constructor(data: INodeExecutionData[][], hints: NodeExecutionHint[] = []) {
|
||||||
|
super();
|
||||||
|
this.push(...data);
|
||||||
|
this.hints = hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHints(): NodeExecutionHint[] {
|
||||||
|
return this.hints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface INodeType {
|
export interface INodeType {
|
||||||
description: INodeTypeDescription;
|
description: INodeTypeDescription;
|
||||||
supplyData?(this: IAllExecuteFunctions, itemIndex: number): Promise<SupplyData>;
|
supplyData?(this: IAllExecuteFunctions, itemIndex: number): Promise<SupplyData>;
|
||||||
|
@ -1745,9 +1759,20 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
|
||||||
}
|
}
|
||||||
| boolean;
|
| boolean;
|
||||||
extendsCredential?: string;
|
extendsCredential?: string;
|
||||||
|
hints?: NodeHint[];
|
||||||
__loadOptionsMethods?: string[]; // only for validation during build
|
__loadOptionsMethods?: string[]; // only for validation during build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NodeHint = {
|
||||||
|
message: string;
|
||||||
|
type?: 'info' | 'warning' | 'danger';
|
||||||
|
location?: 'outputPane' | 'inputPane' | 'ndv';
|
||||||
|
displayCondition?: string;
|
||||||
|
whenToDisplay?: 'always' | 'beforeExecution' | 'afterExecution';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeExecutionHint = Omit<NodeHint, 'whenToDisplay' | 'displayCondition'>;
|
||||||
|
|
||||||
export interface INodeHookDescription {
|
export interface INodeHookDescription {
|
||||||
method: string;
|
method: string;
|
||||||
}
|
}
|
||||||
|
@ -1929,6 +1954,7 @@ export interface ITaskData {
|
||||||
data?: ITaskDataConnections;
|
data?: ITaskDataConnections;
|
||||||
inputOverride?: ITaskDataConnections;
|
inputOverride?: ITaskDataConnections;
|
||||||
error?: ExecutionError;
|
error?: ExecutionError;
|
||||||
|
hints?: NodeExecutionHint[];
|
||||||
source: Array<ISourceData | null>; // Is an array as nodes have multiple inputs
|
source: Array<ISourceData | null>; // Is an array as nodes have multiple inputs
|
||||||
metadata?: ITaskMetadata;
|
metadata?: ITaskMetadata;
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ import type {
|
||||||
INodeInputConfiguration,
|
INodeInputConfiguration,
|
||||||
GenericValue,
|
GenericValue,
|
||||||
DisplayCondition,
|
DisplayCondition,
|
||||||
|
NodeHint,
|
||||||
} from './Interfaces';
|
} from './Interfaces';
|
||||||
import {
|
import {
|
||||||
isFilterValue,
|
isFilterValue,
|
||||||
|
@ -1120,6 +1121,50 @@ export function getNodeInputs(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNodeHints(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
nodeTypeData: INodeTypeDescription,
|
||||||
|
): NodeHint[] {
|
||||||
|
const hints: NodeHint[] = [];
|
||||||
|
|
||||||
|
if (nodeTypeData?.hints?.length) {
|
||||||
|
for (const hint of nodeTypeData.hints) {
|
||||||
|
if (hint.displayCondition) {
|
||||||
|
try {
|
||||||
|
const display = (workflow.expression.getSimpleParameterValue(
|
||||||
|
node,
|
||||||
|
hint.displayCondition,
|
||||||
|
'internal',
|
||||||
|
{},
|
||||||
|
) || false) as boolean;
|
||||||
|
|
||||||
|
if (typeof display !== 'boolean') {
|
||||||
|
console.warn(
|
||||||
|
`Condition was not resolved as boolean in '${node.name}' node for hint: `,
|
||||||
|
hint.message,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (display) {
|
||||||
|
hints.push(hint);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Could not calculate display condition in '${node.name}' node for hint: `,
|
||||||
|
hint.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hints.push(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
export function getNodeOutputs(
|
export function getNodeOutputs(
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
node: INode,
|
node: INode,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import type { INodeParameters, INodeProperties } from '@/Interfaces';
|
import type { INode, INodeParameters, INodeProperties, INodeTypeDescription } from '@/Interfaces';
|
||||||
import { getNodeParameters } from '@/NodeHelpers';
|
import type { Workflow } from '../src';
|
||||||
|
|
||||||
|
import { getNodeParameters, getNodeHints } from '@/NodeHelpers';
|
||||||
|
|
||||||
describe('NodeHelpers', () => {
|
describe('NodeHelpers', () => {
|
||||||
describe('getNodeParameters', () => {
|
describe('getNodeParameters', () => {
|
||||||
|
@ -3437,4 +3439,93 @@ describe('NodeHelpers', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getNodeHints', () => {
|
||||||
|
//TODO: Add more tests here when hints are added to some node types
|
||||||
|
test('should return node hints if present in node type', () => {
|
||||||
|
const testType = {
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
message: 'TEST HINT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as INodeTypeDescription;
|
||||||
|
|
||||||
|
const workflow = {} as unknown as Workflow;
|
||||||
|
|
||||||
|
const node: INode = {
|
||||||
|
name: 'Test Node Hints',
|
||||||
|
} as INode;
|
||||||
|
const nodeType = testType;
|
||||||
|
|
||||||
|
const hints = getNodeHints(workflow, node, nodeType);
|
||||||
|
|
||||||
|
expect(hints).toHaveLength(1);
|
||||||
|
expect(hints[0].message).toEqual('TEST HINT');
|
||||||
|
});
|
||||||
|
test('should not include hint if displayCondition is false', () => {
|
||||||
|
const testType = {
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
message: 'TEST HINT',
|
||||||
|
displayCondition: 'FALSE DISPLAY CONDITION EXPESSION',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as INodeTypeDescription;
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
expression: {
|
||||||
|
getSimpleParameterValue(
|
||||||
|
_node: string,
|
||||||
|
_parameter: string,
|
||||||
|
_mode: string,
|
||||||
|
_additionalData = {},
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Workflow;
|
||||||
|
|
||||||
|
const node: INode = {
|
||||||
|
name: 'Test Node Hints',
|
||||||
|
} as INode;
|
||||||
|
const nodeType = testType;
|
||||||
|
|
||||||
|
const hints = getNodeHints(workflow, node, nodeType);
|
||||||
|
|
||||||
|
expect(hints).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test('should include hint if displayCondition is true', () => {
|
||||||
|
const testType = {
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
message: 'TEST HINT',
|
||||||
|
displayCondition: 'TRUE DISPLAY CONDITION EXPESSION',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as INodeTypeDescription;
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
expression: {
|
||||||
|
getSimpleParameterValue(
|
||||||
|
_node: string,
|
||||||
|
_parameter: string,
|
||||||
|
_mode: string,
|
||||||
|
_additionalData = {},
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Workflow;
|
||||||
|
|
||||||
|
const node: INode = {
|
||||||
|
name: 'Test Node Hints',
|
||||||
|
} as INode;
|
||||||
|
const nodeType = testType;
|
||||||
|
|
||||||
|
const hints = getNodeHints(workflow, node, nodeType);
|
||||||
|
|
||||||
|
expect(hints).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue