feat: No expression error when node hasn’t executed (#8448)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Elias Meire 2024-02-27 10:29:16 +01:00 committed by GitHub
parent 737170893d
commit f9a99ec029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2818 additions and 558 deletions

View file

@ -1,72 +1,136 @@
import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const ndv = new NDV();
const WorkflowPage = new WorkflowPageClass();
describe('Inline expression editor', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();
WorkflowPage.actions.addInitialNodeToCanvas('Schedule');
cy.on('uncaught:exception', (err) => err.name !== 'ExpressionError');
});
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('1 + 2');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^3$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
describe('Static data', () => {
beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();
});
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('"ab"');
WorkflowPage.getters.inlineExpressionEditorInput().type('{rightArrow}+');
WorkflowPage.getters.inlineExpressionEditorInput().type('"cd"');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^abcd$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('1 + 2');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^3$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('true && false');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^false$/);
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('"ab"');
WorkflowPage.getters.inlineExpressionEditorInput().type('{rightArrow}+');
WorkflowPage.getters.inlineExpressionEditorInput().type('"cd"');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^abcd$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('true && false');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^false$/);
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Object: \{"a": 1\}\]$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^1$/);
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Array: \[1,2,3\]\]$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorInput().type('[0]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^1$/);
});
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Object: \{"a": 1\}\]$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
describe('Dynamic data', () => {
beforeEach(() => {
WorkflowPage.actions.openNode('Schedule Trigger');
ndv.actions.setPinnedData([{ myStr: 'Monday' }]);
ndv.actions.close();
WorkflowPage.actions.addNodeToCanvas('No Operation');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();
});
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^1$/);
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
// Resolving $parameter is slow, especially on CI runner
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'getAll');
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Array: \[1,2,3\]\]$/);
it('should resolve input: $json,$input,$(nodeName)', () => {
// Previous nodes have not run, input is empty
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$json.myStr');
WorkflowPage.getters
.inlineExpressionEditorOutput()
.should('have.text', '[Execute previous nodes for preview]');
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$input.item.json.myStr');
WorkflowPage.getters
.inlineExpressionEditorOutput()
.should('have.text', '[Execute previous nodes for preview]');
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type("$('Schedule Trigger').item.json.myStr");
WorkflowPage.getters
.inlineExpressionEditorOutput()
.should('have.text', '[Execute previous nodes for preview]');
WorkflowPage.getters.inlineExpressionEditorInput().clear();
// Run workflow
ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorInput().type('[0]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^1$/);
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
// Resolving $parameter is slow, especially on CI runner
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'getAll');
// Previous nodes have run, input can be resolved
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$json.myStr');
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'Monday');
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$input.item.json.myStr');
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'Monday');
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type("$('Schedule Trigger').item.json.myStr");
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'Monday');
});
});
});

View file

@ -181,11 +181,6 @@ describe('Data mapping', () => {
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`);
ndv.getters
.parameterExpressionPreview('value')
.invoke('text')
.invoke('replace', /\u00a0/g, ' ')
.should('equal', '[ERROR: no data, execute "Schedule Trigger" node first]');
ndv.actions.switchInputMode('Table');
ndv.actions.mapDataFromHeader(1, 'value');
@ -195,7 +190,6 @@ describe('Data mapping', () => {
'have.text',
`{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }} {{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}`,
);
ndv.actions.validateExpressionPreview('value', ' ');
ndv.actions.selectInputNode('Set');

View file

@ -1,62 +1,117 @@
import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Expression editor modal', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();
WorkflowPage.actions.addInitialNodeToCanvas('Schedule');
cy.on('uncaught:exception', (err) => err.name !== 'ExpressionError');
});
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2');
WorkflowPage.getters.expressionModalOutput().contains(/^3$/);
WorkflowPage.getters.expressionModalInput().clear();
describe('Static data', () => {
beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();
});
WorkflowPage.getters.expressionModalInput().type('{{ "ab" + "cd"');
WorkflowPage.getters.expressionModalOutput().contains(/^abcd$/);
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2');
WorkflowPage.getters.expressionModalOutput().contains(/^3$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ "ab" + "cd"');
WorkflowPage.getters.expressionModalOutput().contains(/^abcd$/);
WorkflowPage.getters.expressionModalInput().type('{{ true && false');
WorkflowPage.getters.expressionModalOutput().contains(/^false$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ true && false');
WorkflowPage.getters.expressionModalOutput().contains(/^false$/);
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^\[Object: \{"a": 1\}\]$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3][0]');
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^\[Object: \{"a": 1\}\]$/);
describe('Dynamic data', () => {
beforeEach(() => {
WorkflowPage.actions.openNode('Schedule Trigger');
ndv.actions.setPinnedData([{ myStr: 'Monday' }]);
ndv.actions.close();
WorkflowPage.actions.addNodeToCanvas('No Operation');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();
});
WorkflowPage.getters.expressionModalInput().clear();
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().should('have.text', 'getAll');
});
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
it('should resolve input: $json,$input,$(nodeName)', () => {
// Previous nodes have not run, input is empty
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $json.myStr');
WorkflowPage.getters
.expressionModalOutput()
.should('have.text', '[Execute previous nodes for preview]');
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $input.item.json.myStr');
WorkflowPage.getters
.expressionModalOutput()
.should('have.text', '[Execute previous nodes for preview]');
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type("{{ $('Schedule Trigger').item.json.myStr");
WorkflowPage.getters
.expressionModalOutput()
.should('have.text', '[Execute previous nodes for preview]');
it('should resolve array resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/);
// Run workflow
cy.get('body').type('{esc}');
ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3][0]');
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().should('have.text', 'getAll');
// Previous nodes have run, input can be resolved
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $json.myStr');
WorkflowPage.getters.expressionModalOutput().should('have.text', 'Monday');
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $input.item.json.myStr');
WorkflowPage.getters.expressionModalOutput().should('have.text', 'Monday');
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type("{{ $('Schedule Trigger').item.json.myStr");
WorkflowPage.getters.expressionModalOutput().should('have.text', 'Monday');
});
});
});

View file

@ -74,6 +74,8 @@
--color-valid-resolvable-background: var(--prim-color-alt-a-alpha-025);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c-tint-250);
--color-invalid-resolvable-background: var(--prim-color-alt-c-alpha-02);
--color-pending-resolvable-foreground: var(--color-text-base);
--color-pending-resolvable-background: var(--prim-gray-70-alpha-01);
--color-expression-editor-background: var(--prim-gray-800);
--color-expression-syntax-example: var(--prim-gray-670);

View file

@ -107,6 +107,8 @@
--color-valid-resolvable-background: var(--prim-color-alt-a-tint-500);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c);
--color-invalid-resolvable-background: var(--prim-color-alt-c-tint-450);
--color-pending-resolvable-foreground: var(--color-text-base);
--color-pending-resolvable-background: var(--prim-gray-40);
--color-expression-editor-background: var(--prim-gray-0);
--color-expression-syntax-example: var(--prim-gray-40);

View file

@ -41,6 +41,7 @@
<InlineExpressionEditorOutput
:segments="segments"
:is-read-only="isReadOnly"
:no-input-data="noInputData"
:visible="isFocused"
:hovering-item-number="hoveringItemNumber"
/>
@ -118,6 +119,9 @@ export default defineComponent({
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
},
noInputData(): boolean {
return !this.ndvStore.hasInputData;
},
},
methods: {
focus() {

View file

@ -1,6 +1,6 @@
<template>
<div :class="visible ? $style.dropdown : $style.hidden">
<n8n-text size="small" compact :class="$style.header">
<n8n-text v-if="!noInputData" size="small" compact :class="$style.header">
{{ i18n.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
@ -57,6 +57,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
noInputData: {
type: Boolean,
default: false,
},
hoveringItemNumber: {
type: Number,
required: true,
@ -169,6 +173,10 @@ export default defineComponent({
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
&:first-child {
padding-top: var(--spacing-2xs);
}
}
.footer {

View file

@ -110,6 +110,7 @@
</template>
<NodeExecuteButton
type="secondary"
hide-icon
:transparent="true"
:node-name="isActiveNodeConfig ? rootNode : currentNodeName"
:label="$locale.baseText('ndv.input.noOutputData.executePrevious')"

View file

@ -12,7 +12,7 @@
:label="buttonLabel"
:type="type"
:size="size"
:icon="!isListeningForEvents && 'flask'"
:icon="!isListeningForEvents && !hideIcon && 'flask'"
:transparent-background="transparent"
:title="!isTriggerNode ? $locale.baseText('ndv.execute.testNode.description') : ''"
@click="onClick"
@ -72,6 +72,9 @@ export default defineComponent({
telemetrySource: {
type: String,
},
hideIcon: {
type: Boolean,
},
},
setup(props, ctx) {
const workflowsStore = useWorkflowsStore();

View file

@ -67,11 +67,11 @@ import type {
} from 'n8n-workflow';
import { isResourceLocatorValue } from 'n8n-workflow';
import { get } from 'lodash-es';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import { useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { getExpressionErrorMessage, getResolvableState } from '@/utils/expressions';
export default defineComponent({
name: 'ParameterInputWrapper',
@ -230,9 +230,12 @@ export default defineComponent({
const evaluated = this.evaluatedExpression;
if (!evaluated.ok) {
return `[${this.$locale.baseText('parameterInput.error')}: ${get(
evaluated.error,
'message',
if (getResolvableState(evaluated.error) !== 'invalid') {
return null;
}
return `[${this.$locale.baseText('parameterInput.error')}: ${getExpressionErrorMessage(
evaluated.error as Error,
)}]`;
}

View file

@ -1,19 +1,21 @@
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { ensureSyntaxTree } from '@codemirror/language';
import type { IDataObject } from 'n8n-workflow';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { ensureSyntaxTree } from '@codemirror/language';
import { useNDVStore } from '@/stores/ndv.store';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { EditorView } from '@codemirror/view';
import type { TargetItem } from '@/Interface';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
import type { EditorView } from '@codemirror/view';
import { isEqual } from 'lodash-es';
import { getExpressionErrorMessage, getResolvableState } from '@/utils/expressions';
export const expressionManager = defineComponent({
props: {
@ -32,18 +34,8 @@ export const expressionManager = defineComponent({
editorState: undefined,
};
},
watch: {
targetItem() {
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
});
},
},
computed: {
...mapStores(useNDVStore),
...mapStores(useNDVStore, useWorkflowsStore),
unresolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
@ -116,7 +108,7 @@ export const expressionManager = defineComponent({
const { from, to, text, token } = segment;
if (token === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
const { resolved, fullError } = this.resolve(text, this.hoveringItem);
acc.push({
kind: 'resolvable',
@ -127,8 +119,8 @@ export const expressionManager = defineComponent({
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
// This fixes that but as as TODO we should figure out why this is happening
resolved: String(resolved),
error,
fullError,
state: getResolvableState(fullError),
error: fullError,
});
return acc;
@ -188,6 +180,16 @@ export const expressionManager = defineComponent({
});
},
},
watch: {
targetItem() {
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
});
},
},
methods: {
isEmptyExpression(resolvable: string) {
return /\{\{\s*\}\}/.test(resolvable);
@ -220,7 +222,7 @@ export const expressionManager = defineComponent({
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
}
} catch (error) {
result.resolved = `[${error.message}]`;
result.resolved = `[${getExpressionErrorMessage(error)}]`;
result.error = true;
result.fullError = error;
}

View file

@ -378,7 +378,9 @@ export const pushConnection = defineComponent({
if (
error.context.nodeCause &&
['no pairing info', 'invalid pairing info'].includes(error.context.type as string)
['paired_item_no_info', 'paired_item_invalid_info'].includes(
error.context.type as string,
)
) {
const node = workflow.getNode(error.context.nodeCause as string);

View file

@ -4,11 +4,17 @@ import { StateField, StateEffect } from '@codemirror/state';
import { tags } from '@lezer/highlight';
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import type { ColoringStateEffect, Plaintext, Resolvable } from '@/types/expressions';
import type {
ColoringStateEffect,
Plaintext,
Resolvable,
ResolvableState,
} from '@/types/expressions';
const cssClasses = {
validResolvable: 'cm-valid-resolvable',
invalidResolvable: 'cm-invalid-resolvable',
pendingResolvable: 'cm-pending-resolvable',
brokenResolvable: 'cm-broken-resolvable',
plaintext: 'cm-plaintext',
};
@ -22,20 +28,25 @@ const resolvablesTheme = EditorView.theme({
color: 'var(--color-invalid-resolvable-foreground)',
backgroundColor: 'var(--color-invalid-resolvable-background)',
},
['.' + cssClasses.pendingResolvable]: {
color: 'var(--color-pending-resolvable-foreground)',
backgroundColor: 'var(--color-pending-resolvable-background)',
},
});
const marks = {
const resolvableStateToDecoration: Record<ResolvableState, Decoration> = {
valid: Decoration.mark({ class: cssClasses.validResolvable }),
invalid: Decoration.mark({ class: cssClasses.invalidResolvable }),
pending: Decoration.mark({ class: cssClasses.pendingResolvable }),
};
const coloringStateEffects = {
addColorEffect: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to, kind, error }, change) => ({
map: ({ from, to, kind, state }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
kind,
error,
state,
}),
}),
removeColorEffect: StateEffect.define<ColoringStateEffect.Value>({
@ -66,7 +77,7 @@ const coloringStateField = StateField.define<DecorationSet>({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = txEffect.value.error ? marks.invalid : marks.valid;
const decoration = resolvableStateToDecoration[txEffect.value.state ?? 'pending'];
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
@ -81,8 +92,8 @@ const coloringStateField = StateField.define<DecorationSet>({
});
function addColor(view: EditorView, segments: Resolvable[]) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, error }) =>
coloringStateEffects.addColorEffect.of({ from, to, kind, error }),
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, state }) =>
coloringStateEffects.addColorEffect.of({ from, to, kind, state }),
);
if (effects.length === 0) return;

View file

@ -662,6 +662,12 @@
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",
"expressionModalInput.noExecutionData": "Execute previous nodes for preview",
"expressionModalInput.noNodeExecutionData": "Execute node {node} for preview",
"expressionModalInput.noInputConnection": "No input connected",
"expressionModalInput.pairedItemConnectionError": "No path back to node",
"expressionModalInput.pairedItemInvalidPinnedError": "Unpin node {node} and execute",
"expressionModalInput.pairedItemError": "Cant determine which item to use",
"fakeDoor.settings.environments.name": "Environments",
"fakeDoor.settings.sso.name": "Single Sign-On",
"fakeDoor.settings.sso.actionBox.title": "Were working on this (as a paid feature)",
@ -780,7 +786,7 @@
"ndv.input.noOutputDataInBranch": "No input data in this branch",
"ndv.input.noOutputDataInNode": "Node did not output any data. n8n stops executing the workflow when a node has no output data.",
"ndv.input.noOutputData": "No data",
"ndv.input.noOutputData.executePrevious": "Test previous nodes",
"ndv.input.noOutputData.executePrevious": "Execute previous nodes",
"ndv.input.noOutputData.title": "No input data yet",
"ndv.input.noOutputData.hint": "(From the earliest node that has no output data yet)",
"ndv.input.executingPrevious": "Executing previous nodes...",

View file

@ -75,6 +75,10 @@ export const useNDVStore = defineStore(STORES.NDV, {
return executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data
?.main?.[inputBranchIndex];
},
hasInputData(): boolean {
const data = this.ndvInputData;
return data && data.length > 0;
},
getPanelDisplayMode() {
return (panel: NodePanelType) => this[panel].displayMode;
},

View file

@ -8,12 +8,14 @@ export type Plaintext = { kind: 'plaintext'; plaintext: string } & Range;
export type Html = Plaintext; // for n8n parser, functionally identical to plaintext
export type ResolvableState = 'valid' | 'invalid' | 'pending';
export type Resolvable = {
kind: 'resolvable';
resolvable: string;
resolved: unknown;
error: boolean;
fullError: Error | null;
state: ResolvableState;
error: Error | null;
} & Range;
export type Resolved = Resolvable;
@ -21,6 +23,6 @@ export type Resolved = Resolvable;
export namespace ColoringStateEffect {
export type Value = {
kind?: 'plaintext' | 'resolvable';
error?: boolean;
state?: ResolvableState;
} & Range;
}

View file

@ -1,4 +1,7 @@
import { ExpressionParser } from 'n8n-workflow';
import type { ResolvableState } from '@/types/expressions';
import { ExpressionError, ExpressionParser } from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
export const isExpression = (expr: unknown) => {
if (typeof expr !== 'string') return false;
@ -13,3 +16,88 @@ export const isTestableExpression = (expr: string) => {
return /\$secrets(\.[a-zA-Z0-9_]+)+$/.test(c.text.trim());
});
};
export const isNoExecDataExpressionError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'no_execution_data';
};
export const isNoNodeExecDataExpressionError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'no_node_execution_data';
};
export const isPairedItemIntermediateNodesError = (error: unknown): error is ExpressionError => {
return (
error instanceof ExpressionError && error.context.type === 'paired_item_intermediate_nodes'
);
};
export const isPairedItemNoConnectionError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'paired_item_no_connection';
};
export const isInvalidPairedItemError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'paired_item_invalid_info';
};
export const isNoPairedItemError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'paired_item_no_info';
};
export const isNoInputConnectionError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.type === 'no_input_connection';
};
export const isAnyPairedItemError = (error: unknown): error is ExpressionError => {
return error instanceof ExpressionError && error.context.functionality === 'pairedItem';
};
export const getResolvableState = (error: unknown): ResolvableState => {
if (!error) return 'valid';
if (
isNoExecDataExpressionError(error) ||
isNoNodeExecDataExpressionError(error) ||
isPairedItemIntermediateNodesError(error)
) {
return 'pending';
}
return 'invalid';
};
export const getExpressionErrorMessage = (error: Error): string => {
if (isNoExecDataExpressionError(error) || isPairedItemIntermediateNodesError(error)) {
return i18n.baseText('expressionModalInput.noExecutionData');
}
if (isNoNodeExecDataExpressionError(error)) {
const nodeCause = error.context.nodeCause as string;
return i18n.baseText('expressionModalInput.noNodeExecutionData', {
interpolate: { node: nodeCause },
});
}
if (isNoInputConnectionError(error)) {
return i18n.baseText('expressionModalInput.noInputConnection');
}
if (isPairedItemNoConnectionError(error)) {
return i18n.baseText('expressionModalInput.pairedItemConnectionError');
}
if (isInvalidPairedItemError(error) || isNoPairedItemError(error)) {
const nodeCause = error.context.nodeCause as string;
const isPinned = !!useWorkflowsStore().pinDataByNodeName(nodeCause);
if (isPinned) {
return i18n.baseText('expressionModalInput.pairedItemInvalidPinnedError', {
interpolate: { node: nodeCause },
});
}
}
if (isAnyPairedItemError(error)) {
return i18n.baseText('expressionModalInput.pairedItemError');
}
return error.message;
};

View file

@ -2584,6 +2584,6 @@ export type BannerName =
| 'NON_PRODUCTION_LICENSE'
| 'EMAIL_CONFIRMATION';
export type Functionality = 'regular' | 'configuration-node';
export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
export type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };

View file

@ -22,7 +22,7 @@ import type {
ProxyInput,
} from './Interfaces';
import * as NodeHelpers from './NodeHelpers';
import { ExpressionError } from './errors/expression.error';
import { ExpressionError, type ExpressionErrorOptions } from './errors/expression.error';
import type { Workflow } from './Workflow';
import { augmentArray, augmentObject } from './AugmentObject';
import { deepCopy } from './utils';
@ -104,6 +104,7 @@ export class WorkflowDataProxy {
{
runIndex: that.runIndex,
itemIndex: that.itemIndex,
type: 'no_execution_data',
},
);
}
@ -274,19 +275,25 @@ export class WorkflowDataProxy {
);
}
if (!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
if (that.workflow.getNode(nodeName)) {
throw new ExpressionError(`no data, execute "${nodeName}" node first`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
if (!that.workflow.getNode(nodeName)) {
throw new ExpressionError(`"${nodeName}" node doesn't exist`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
});
}
if (
!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName)
) {
throw new ExpressionError(`no data, execute "${nodeName}" node first`, {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
type: 'no_node_execution_data',
nodeCause: nodeName,
});
}
runIndex = runIndex === undefined ? that.defaultReturnRunIndex : runIndex;
runIndex =
runIndex === -1 ? that.runExecutionData.resultData.runData[nodeName].length - 1 : runIndex;
@ -372,6 +379,28 @@ export class WorkflowDataProxy {
if (['binary', 'data', 'json'].includes(name)) {
const executionData = that.getNodeExecutionData(nodeName, shortSyntax, undefined);
if (executionData.length === 0) {
if (that.workflow.getParentNodes(nodeName).length === 0) {
throw new ExpressionError('No execution data available', {
messageTemplate:
'No execution data available to expression under %%PARAMETER%%',
description:
'This node has no input data. Please make sure this node is connected to another node.',
nodeCause: nodeName,
runIndex: that.runIndex,
itemIndex: that.itemIndex,
type: 'no_input_connection',
});
}
throw new ExpressionError('No execution data available', {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
type: 'no_execution_data',
});
}
if (executionData.length <= that.itemIndex) {
throw new ExpressionError(`No data found for item-index: "${that.itemIndex}"`, {
runIndex: that.runIndex,
@ -615,22 +644,13 @@ export class WorkflowDataProxy {
const createExpressionError = (
message: string,
context?: {
causeDetailed?: string;
description?: string;
descriptionTemplate?: string;
functionality?: 'pairedItem';
context?: ExpressionErrorOptions & {
moreInfoLink?: boolean;
functionOverrides?: {
// Custom data to display for Function-Nodes
message?: string;
description?: string;
};
itemIndex?: number;
messageTemplate?: string;
moreInfoLink?: boolean;
nodeCause?: string;
runIndex?: number;
type?: string;
},
) => {
if (isScriptingNode(that.activeNodeName, that.workflow) && context?.functionOverrides) {
@ -678,7 +698,7 @@ export class WorkflowDataProxy {
incomingSourceData: ISourceData | null,
pairedItem: IPairedItemData,
): INodeExecutionData | null => {
let taskData: ITaskData;
let taskData: ITaskData | undefined;
let sourceData: ISourceData | null = incomingSourceData;
@ -697,13 +717,12 @@ export class WorkflowDataProxy {
let nodeBeforeLast: string | undefined;
while (sourceData !== null && destinationNodeName !== sourceData.previousNode) {
taskData =
that.runExecutionData!.resultData.runData[sourceData.previousNode][
sourceData?.previousNodeRun || 0
];
const runIndex = sourceData?.previousNodeRun || 0;
const previousNodeOutput = sourceData.previousNodeOutput || 0;
if (previousNodeOutput >= taskData.data!.main.length) {
taskData =
that.runExecutionData?.resultData?.runData?.[sourceData.previousNode]?.[runIndex];
if (taskData?.data?.main && previousNodeOutput >= taskData.data.main.length) {
throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
functionOverrides: {
@ -716,7 +735,12 @@ export class WorkflowDataProxy {
});
}
if (pairedItem.item >= taskData.data!.main[previousNodeOutput]!.length) {
const previousNodeOutputData =
taskData?.data?.main?.[previousNodeOutput] ??
(that.workflow.getPinDataOfNode(sourceData.previousNode) as INodeExecutionData[]);
const source = taskData?.source ?? [];
if (pairedItem.item >= previousNodeOutputData.length) {
throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
functionality: 'pairedItem',
@ -733,13 +757,12 @@ export class WorkflowDataProxy {
}points to an input item on node <strong>${
sourceData.previousNode
}</strong> that doesnt exist.`,
type: 'invalid pairing info',
type: 'paired_item_invalid_info',
moreInfoLink: true,
});
}
const itemPreviousNode: INodeExecutionData =
taskData.data!.main[previousNodeOutput]![pairedItem.item];
const itemPreviousNode: INodeExecutionData = previousNodeOutputData[pairedItem.item];
if (itemPreviousNode.pairedItem === undefined) {
throw createExpressionError('Cant get data for expression', {
@ -751,7 +774,7 @@ export class WorkflowDataProxy {
nodeCause: sourceData.previousNode,
description: `To fetch the data from other nodes that this expression needs, more information is needed from the node <strong>${sourceData.previousNode}</strong>`,
causeDetailed: `Missing pairedItem data (node ${sourceData.previousNode} probably didnt supply it)`,
type: 'no pairing info',
type: 'paired_item_no_info',
moreInfoLink: true,
});
}
@ -763,13 +786,13 @@ export class WorkflowDataProxy {
.map((item) => {
try {
const itemInput = item.input || 0;
if (itemInput >= taskData.source.length) {
if (itemInput >= source.length) {
// `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData!.previousNode}'.`
// Actual error does not matter as it gets caught below and `null` will be returned
throw new ApplicationError('Not found');
}
return getPairedItem(destinationNodeName, taskData.source[itemInput], item);
return getPairedItem(destinationNodeName, source[itemInput], item);
} catch (error) {
// Means pairedItem could not be found
return null;
@ -793,7 +816,7 @@ export class WorkflowDataProxy {
message: 'Invalid code',
},
description: `The expression uses data in the node <strong>${destinationNodeName}</strong> but there is more than one matching item in that node`,
type: 'multiple matches',
type: 'paired_item_multiple_matches',
});
}
@ -812,8 +835,8 @@ export class WorkflowDataProxy {
}
const itemInput = pairedItem.input || 0;
if (itemInput >= taskData.source.length) {
if (taskData.source.length === 0) {
if (itemInput >= source.length) {
if (source.length === 0) {
// A trigger node got reached, so looks like that that item can not be resolved
throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%',
@ -823,7 +846,7 @@ export class WorkflowDataProxy {
message: 'Invalid code',
},
description: `The expression uses data in the node <strong>${destinationNodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
type: 'no connection',
type: 'paired_item_no_connection',
moreInfoLink: true,
});
}
@ -841,12 +864,12 @@ export class WorkflowDataProxy {
? `of run ${(sourceData.previousNodeRun || 0).toString()} `
: ''
}points to a branch that doesnt exist.`,
type: 'invalid pairing info',
type: 'paired_item_invalid_info',
});
}
nodeBeforeLast = sourceData.previousNode;
sourceData = taskData.source[pairedItem.input || 0] || null;
sourceData = source[pairedItem.input || 0] || null;
if (pairedItem.sourceOverwrite) {
sourceData = pairedItem.sourceOverwrite;
@ -862,7 +885,7 @@ export class WorkflowDataProxy {
},
nodeCause: nodeBeforeLast,
description: 'Could not resolve, probably no pairedItem exists',
type: 'no pairing info',
type: 'paired_item_no_info',
moreInfoLink: true,
});
}
@ -882,7 +905,7 @@ export class WorkflowDataProxy {
},
description: 'Item points to a node output which does not exist',
causeDetailed: `The sourceData points to a node output ${previousNodeOutput} which does not exist on node ${sourceData.previousNode} (output node did probably supply a wrong one)`,
type: 'invalid pairing info',
type: 'paired_item_invalid_info',
});
}
@ -903,7 +926,7 @@ export class WorkflowDataProxy {
}points to an input item on node <strong>${
sourceData.previousNode
}</strong> that doesnt exist.`,
type: 'invalid pairing info',
type: 'paired_item_invalid_info',
moreInfoLink: true,
});
}
@ -922,6 +945,18 @@ export class WorkflowDataProxy {
throw createExpressionError(`"${nodeName}" node doesn't exist`);
}
const ensureNodeExecutionData = () => {
if (
!that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName)
) {
throw createExpressionError(`no data, execute "${nodeName}" node first`, {
type: 'no_node_execution_data',
nodeCause: nodeName,
});
}
};
return new Proxy(
{},
{
@ -942,13 +977,38 @@ export class WorkflowDataProxy {
get(target, property, receiver) {
if (property === 'isProxy') return true;
if (!that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName)) {
if (property === 'isExecuted') return false;
throw createExpressionError(`no data, execute "${nodeName}" node first`);
if (property === 'isExecuted') {
return (
that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) ?? false
);
}
if (property === 'isExecuted') return true;
if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
// Before resolving the pairedItem make sure that the requested node comes in the
// graph before the current one
const activeNode = that.workflow.getNode(that.activeNodeName);
let contextNode = that.contextNodeName;
if (activeNode) {
const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode);
contextNode = parentMainInputNode.name ?? contextNode;
}
const parentNodes = that.workflow.getParentNodes(contextNode);
if (!parentNodes.includes(nodeName)) {
throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%',
functionality: 'pairedItem',
functionOverrides: {
description: `The code uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
message: `No path back to node ${nodeName}`,
},
description: `The expression uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
nodeCause: nodeName,
type: 'paired_item_no_connection',
});
}
ensureNodeExecutionData();
const pairedItemMethod = (itemIndex?: number) => {
if (itemIndex === undefined) {
if (property === 'itemMatching') {
@ -960,11 +1020,26 @@ export class WorkflowDataProxy {
}
const executionData = that.connectionInputData;
const input = executionData[itemIndex];
if (!input) {
throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
functionality: 'pairedItem',
functionOverrides: {
description: `Some intermediate nodes between <strong>${nodeName}</strong> and <strong>${that.activeNodeName}</strong> have not executed yet.`,
message: 'Cant get data',
},
description: `Some intermediate nodes between <strong>${nodeName}</strong> and <strong>${that.activeNodeName}</strong> have not executed yet.`,
causeDetailed: `pairedItem can\'t be found when intermediate nodes between <strong>${nodeName}</strong> and <strong>${that.activeNodeName}</strong> have not executed yet.`,
itemIndex,
type: 'paired_item_intermediate_nodes',
});
}
// As we operate on the incoming item we can be sure that pairedItem is not an
// array. After all can it only come from exactly one previous node via a certain
// input. For that reason do we not have to consider the array case.
const pairedItem = executionData[itemIndex].pairedItem as IPairedItemData;
const pairedItem = input.pairedItem as IPairedItemData;
if (pairedItem === undefined) {
throw createExpressionError('Cant get data for expression', {
@ -994,28 +1069,6 @@ export class WorkflowDataProxy {
});
}
// Before resolving the pairedItem make sure that the requested node comes in the
// graph before the current one
const activeNode = that.workflow.getNode(that.activeNodeName);
let contextNode = that.contextNodeName;
if (activeNode) {
const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode);
contextNode = parentMainInputNode.name ?? contextNode;
}
const parentNodes = that.workflow.getParentNodes(contextNode);
if (!parentNodes.includes(nodeName)) {
throw createExpressionError('Invalid expression', {
messageTemplate: 'Invalid expression under %%PARAMETER%%',
functionality: 'pairedItem',
functionOverrides: {
description: `The code uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
message: `No path back to node ${nodeName}`,
},
description: `The expression uses data in the node <strong>${nodeName}</strong> but there is no path back to it. Please check this node is connected to it (there can be other nodes in between).`,
itemIndex,
});
}
const sourceData: ISourceData | null =
that.executeData.source.main[pairedItem.input || 0] ??
that.executeData.source.main[0];
@ -1029,6 +1082,7 @@ export class WorkflowDataProxy {
return pairedItemMethod;
}
if (property === 'first') {
ensureNodeExecutionData();
return (branchIndex?: number, runIndex?: number) => {
const executionData = getNodeOutput(nodeName, branchIndex, runIndex);
if (executionData[0]) return executionData[0];
@ -1036,6 +1090,7 @@ export class WorkflowDataProxy {
};
}
if (property === 'last') {
ensureNodeExecutionData();
return (branchIndex?: number, runIndex?: number) => {
const executionData = getNodeOutput(nodeName, branchIndex, runIndex);
if (!executionData.length) return undefined;
@ -1046,6 +1101,7 @@ export class WorkflowDataProxy {
};
}
if (property === 'all') {
ensureNodeExecutionData();
return (branchIndex?: number, runIndex?: number) =>
getNodeOutput(nodeName, branchIndex, runIndex);
}
@ -1075,6 +1131,14 @@ export class WorkflowDataProxy {
get(target, property, receiver) {
if (property === 'isProxy') return true;
if (that.connectionInputData.length === 0) {
throw createExpressionError('No execution data available', {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
type: 'no_execution_data',
});
}
if (property === 'item') {
return that.connectionInputData[that.itemIndex];
}

View file

@ -1,26 +1,34 @@
import type { IDataObject } from '../Interfaces';
import { ExecutionBaseError } from './abstract/execution-base.error';
export interface ExpressionErrorOptions {
cause?: Error;
causeDetailed?: string;
description?: string;
descriptionTemplate?: string;
functionality?: 'pairedItem';
itemIndex?: number;
messageTemplate?: string;
nodeCause?: string;
parameter?: string;
runIndex?: number;
type?:
| 'no_execution_data'
| 'no_node_execution_data'
| 'no_input_connection'
| 'internal'
| 'paired_item_invalid_info'
| 'paired_item_no_info'
| 'paired_item_multiple_matches'
| 'paired_item_no_connection'
| 'paired_item_intermediate_nodes';
}
/**
* Class for instantiating an expression error
*/
export class ExpressionError extends ExecutionBaseError {
constructor(
message: string,
options?: {
cause?: Error;
causeDetailed?: string;
description?: string;
descriptionTemplate?: string;
functionality?: 'pairedItem';
itemIndex?: number;
messageTemplate?: string;
nodeCause?: string;
parameter?: string;
runIndex?: number;
type?: string;
},
) {
constructor(message: string, options?: ExpressionErrorOptions) {
super(message, { cause: options?.cause });
if (options?.description !== undefined) {

View file

@ -11,6 +11,7 @@ import type { INodeExecutionData } from '@/Interfaces';
import { extendSyntax } from '@/Extensions/ExpressionExtension';
import { ExpressionError } from '@/errors/expression.error';
import { setDifferEnabled, setEvaluator } from '@/ExpressionEvaluatorProxy';
import { workflow } from './ExpressionExtensions/Helpers';
setDifferEnabled(true);
@ -172,24 +173,6 @@ for (const evaluator of ['tmpl', 'tournament'] as const) {
});
describe('Test all expression value fixtures', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
id: '1',
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = workflow.expression;
const evaluate = (value: string, data: INodeExecutionData[]) => {
@ -202,12 +185,18 @@ for (const evaluator of ['tmpl', 'tournament'] as const) {
continue;
}
test(t.expression, () => {
for (const test of t.tests.filter(
const evaluationTests = t.tests.filter(
(test) => test.type === 'evaluation',
) as ExpressionTestEvaluation[]) {
expect(
evaluate(t.expression, test.input.map((d) => ({ json: d })) as any),
).toStrictEqual(test.output);
) as ExpressionTestEvaluation[];
for (const test of evaluationTests) {
const input = test.input.map((d) => ({ json: d })) as any;
if ('error' in test) {
expect(() => evaluate(t.expression, input)).toThrowError(test.error);
} else {
expect(evaluate(t.expression, input)).toStrictEqual(test.output);
}
}
});
}

View file

@ -1,15 +1,22 @@
import type { GenericValue, IDataObject } from '@/Interfaces';
import { ExpressionError } from '@/errors/expression.error';
export interface ExpressionTestBase {
type: string;
interface ExpressionTestBase {
type: 'evaluation' | 'transform';
}
export interface ExpressionTestEvaluation extends ExpressionTestBase {
interface ExpressionTestSuccess extends ExpressionTestBase {
type: 'evaluation';
input: Array<IDataObject | GenericValue>;
output: IDataObject | GenericValue;
}
interface ExpressionTestFailure extends ExpressionTestBase {
type: 'evaluation';
input: Array<IDataObject | GenericValue>;
error: ExpressionError;
}
export interface ExpressionTestTransform extends ExpressionTestBase {
type: 'transform';
// If we don't specify a result we expect it to be the same as the input
@ -17,6 +24,7 @@ export interface ExpressionTestTransform extends ExpressionTestBase {
forceTransform?: boolean;
}
export type ExpressionTestEvaluation = ExpressionTestSuccess | ExpressionTestFailure;
export type ExpressionTests = ExpressionTestEvaluation | ExpressionTestTransform;
export interface ExpressionTestFixture {
@ -265,7 +273,11 @@ export const baseFixtures: ExpressionTestFixture[] = [
{
type: 'evaluation',
input: [],
output: undefined,
error: new ExpressionError('No execution data available', {
runIndex: 0,
itemIndex: 0,
type: 'no_execution_data',
}),
},
{ type: 'transform' },
{ type: 'transform', forceTransform: true },

View file

@ -38,6 +38,8 @@ import { deepCopy } from '@/utils';
import { getGlobalState } from '@/GlobalState';
import { ApplicationError } from '@/errors/application.error';
import { NodeTypes as NodeTypesClass } from './NodeTypes';
import { readFileSync } from 'fs';
import path from 'path';
export interface INodeTypesObject {
[key: string]: INodeType;
@ -558,3 +560,7 @@ export function WorkflowExecuteAdditionalData(): IWorkflowExecuteAdditionalData
userId: '123',
};
}
const BASE_DIR = path.resolve(__dirname, '..');
export const readJsonFileSync = <T>(filePath: string) =>
JSON.parse(readFileSync(path.join(BASE_DIR, filePath), 'utf-8')) as T;

View file

@ -536,42 +536,45 @@ const googleSheetsNode: LoadedClass<IVersionedNodeType> = {
},
};
const setNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
group: ['input'],
version: 1,
description: 'Sets a value',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Value1',
name: 'value1',
type: 'string',
default: 'default-value1',
},
{
displayName: 'Value2',
name: 'value2',
type: 'string',
default: 'default-value2',
},
],
},
},
};
export class NodeTypes implements INodeTypes {
nodeTypes: INodeTypeData = {
'n8n-nodes-base.stickyNote': stickyNode,
'n8n-nodes-base.set': setNode,
'test.googleSheets': googleSheetsNode,
'test.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
group: ['input'],
version: 1,
description: 'Sets a value',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Value1',
name: 'value1',
type: 'string',
default: 'default-value1',
},
{
displayName: 'Value2',
name: 'value2',
type: 'string',
default: 'default-value2',
},
],
},
},
},
'test.set': setNode,
'test.setMulti': {
sourcePath: '',
type: {

View file

@ -1,344 +1,144 @@
import type { IConnections, IExecuteData, INode, IRunExecutionData } from '@/Interfaces';
import type { IExecuteData, INode, IRun, IWorkflowBase } from '@/Interfaces';
import { Workflow } from '@/Workflow';
import { WorkflowDataProxy } from '@/WorkflowDataProxy';
import * as Helpers from './Helpers';
import { ExpressionError } from '@/errors/expression.error';
import * as Helpers from './Helpers';
describe('WorkflowDataProxy', () => {
describe('test data proxy', () => {
const nodes: INode[] = [
{
name: 'Start',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-1',
position: [100, 200],
},
{
name: 'Function',
type: 'test.set',
parameters: {
functionCode:
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
},
typeVersion: 1,
id: 'uuid-2',
position: [280, 200],
},
{
name: 'Rename',
type: 'test.set',
parameters: {
value1: 'data',
value2: 'initialName',
},
typeVersion: 1,
id: 'uuid-3',
position: [460, 200],
},
{
name: 'Set',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-4',
position: [640, 200],
},
{
name: 'End',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-5',
position: [640, 200],
},
];
const loadFixture = (fixture: string) => {
const workflow = Helpers.readJsonFileSync<IWorkflowBase>(
`test/fixtures/WorkflowDataProxy/${fixture}_workflow.json`,
);
const run = Helpers.readJsonFileSync<IRun>(`test/fixtures/WorkflowDataProxy/${fixture}_run.json`);
const connections: IConnections = {
Start: {
main: [
[
{
node: 'Function',
type: 'main',
index: 0,
},
],
],
},
Function: {
main: [
[
{
node: 'Rename',
type: 'main',
index: 0,
},
],
],
},
Rename: {
main: [
[
{
node: 'End',
type: 'main',
index: 0,
},
],
],
return { workflow, run };
};
const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNode: string) => {
const taskData = run?.data.resultData.runData[activeNode]?.[0];
const lastNodeConnectionInputData = taskData?.data?.main[0];
let executeData: IExecuteData | undefined;
if (taskData) {
executeData = {
data: taskData.data!,
node: workflow.nodes.find((node) => node.name === activeNode) as INode,
source: {
main: taskData.source,
},
};
}
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {
Start: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: {},
},
],
],
},
source: [],
},
],
Function: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { initialName: 105 },
pairedItem: { item: 0 },
},
{
json: { initialName: 160 },
pairedItem: { item: 0 },
},
{
json: { initialName: 121 },
pairedItem: { item: 0 },
},
{
json: { initialName: 275 },
pairedItem: { item: 0 },
},
{
json: { initialName: 950 },
pairedItem: { item: 0 },
},
],
],
},
source: [
{
previousNode: 'Start',
},
],
},
],
Rename: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { data: 105 },
pairedItem: { item: 0 },
},
{
json: { data: 160 },
pairedItem: { item: 1 },
},
{
json: { data: 121 },
pairedItem: { item: 2 },
},
{
json: { data: 275 },
pairedItem: { item: 3 },
},
{
json: { data: 950 },
pairedItem: { item: 4 },
},
],
],
},
source: [
{
previousNode: 'Function',
},
],
},
],
End: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { data: 105 },
pairedItem: { item: 0 },
},
{
json: { data: 160 },
pairedItem: { item: 1 },
},
{
json: { data: 121 },
pairedItem: { item: 2 },
},
{
json: { data: 275 },
pairedItem: { item: 3 },
},
{
json: { data: 950 },
pairedItem: { item: 4 },
},
],
],
},
source: [
{
previousNode: 'Rename',
},
],
},
],
},
},
};
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
const dataProxy = new WorkflowDataProxy(
new Workflow({
id: '123',
name: 'test workflow',
nodes,
connections,
nodes: workflow.nodes,
connections: workflow.connections,
active: false,
nodeTypes,
});
const nameLastNode = 'End';
nodeTypes: Helpers.NodeTypes(),
}),
run?.data ?? null,
0,
0,
activeNode,
lastNodeConnectionInputData ?? [],
{},
'manual',
{},
executeData,
);
const lastNodeConnectionInputData =
runExecutionData.resultData.runData[nameLastNode][0].data!.main[0];
return dataProxy.getDataProxy();
};
const executeData: IExecuteData = {
data: runExecutionData.resultData.runData[nameLastNode][0].data!,
node: nodes.find((node) => node.name === nameLastNode) as INode,
source: {
main: runExecutionData.resultData.runData[nameLastNode][0].source,
},
};
describe('WorkflowDataProxy', () => {
describe('Base', () => {
const fixture = loadFixture('base');
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End');
const dataProxy = new WorkflowDataProxy(
workflow,
runExecutionData,
0,
0,
nameLastNode,
lastNodeConnectionInputData ?? [],
{},
'manual',
{},
executeData,
);
const proxy = dataProxy.getDataProxy();
test('test $("NodeName").all()', () => {
test('$("NodeName").all()', () => {
expect(proxy.$('Rename').all()[1].json.data).toEqual(160);
});
test('test $("NodeName").all() length', () => {
test('$("NodeName").all() length', () => {
expect(proxy.$('Rename').all().length).toEqual(5);
});
test('test $("NodeName").item', () => {
test('$("NodeName").item', () => {
expect(proxy.$('Rename').item).toEqual({ json: { data: 105 }, pairedItem: { item: 0 } });
});
test('test $("NodeNameEarlier").item', () => {
test('$("NodeNameEarlier").item', () => {
expect(proxy.$('Function').item).toEqual({
json: { initialName: 105 },
pairedItem: { item: 0 },
});
});
test('test $("NodeName").itemMatching(2)', () => {
test('$("NodeName").itemMatching(2)', () => {
expect(proxy.$('Rename').itemMatching(2).json.data).toEqual(121);
});
test('test $("NodeName").first()', () => {
test('$("NodeName").first()', () => {
expect(proxy.$('Rename').first().json.data).toEqual(105);
});
test('test $("NodeName").last()', () => {
test('$("NodeName").last()', () => {
expect(proxy.$('Rename').last().json.data).toEqual(950);
});
test('test $("NodeName").params', () => {
test('$("NodeName").params', () => {
expect(proxy.$('Rename').params).toEqual({ value1: 'data', value2: 'initialName' });
});
test('$("NodeName")', () => {
test('$("NodeName") not in workflow should throw', () => {
expect(() => proxy.$('doNotExist')).toThrowError(ExpressionError);
});
test('test $("NodeName").isExecuted', () => {
test('$("NodeName").item on Node that has not executed', () => {
expect(() => proxy.$('Set').item).toThrowError(ExpressionError);
});
test('$("NodeName").isExecuted', () => {
expect(proxy.$('Function').isExecuted).toEqual(true);
expect(proxy.$('Set').isExecuted).toEqual(false);
});
test('test $input.all()', () => {
test('$input.all()', () => {
expect(proxy.$input.all()[1].json.data).toEqual(160);
});
test('test $input.all() length', () => {
test('$input.all() length', () => {
expect(proxy.$input.all().length).toEqual(5);
});
test('test $input.first()', () => {
expect(proxy.$input.first().json.data).toEqual(105);
test('$input.first()', () => {
expect(proxy.$input.first()?.json?.data).toEqual(105);
});
test('test $input.last()', () => {
expect(proxy.$input.last().json.data).toEqual(950);
test('$input.last()', () => {
expect(proxy.$input.last()?.json?.data).toEqual(950);
});
test('test $input.item', () => {
expect(proxy.$input.item.json.data).toEqual(105);
test('$input.item', () => {
expect(proxy.$input.item?.json?.data).toEqual(105);
});
test('test $thisItem', () => {
test('$thisItem', () => {
expect(proxy.$thisItem.json.data).toEqual(105);
});
test('test $binary', () => {
test('$binary', () => {
expect(proxy.$binary).toEqual({});
});
test('test $json', () => {
test('$json', () => {
expect(proxy.$json).toEqual({ data: 105 });
});
test('test $itemIndex', () => {
test('$itemIndex', () => {
expect(proxy.$itemIndex).toEqual(0);
});
test('test $prevNode', () => {
test('$prevNode', () => {
expect(proxy.$prevNode).toEqual({ name: 'Rename', outputIndex: 0, runIndex: 0 });
});
test('test $runIndex', () => {
test('$runIndex', () => {
expect(proxy.$runIndex).toEqual(0);
});
test('test $workflow', () => {
test('$workflow', () => {
expect(proxy.$workflow).toEqual({
active: false,
id: '123',
@ -346,4 +146,141 @@ describe('WorkflowDataProxy', () => {
});
});
});
describe('Errors', () => {
const fixture = loadFixture('errors');
test('$("NodeName").item, Node does not exist', (done) => {
const proxy = getProxyFromFixture(
fixture.workflow,
fixture.run,
'Reference non-existent node',
);
try {
proxy.$('does not exist').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('"does not exist" node doesn\'t exist');
done();
}
});
test('$("NodeName").item, node has no connection to referenced node', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'NoPathBack');
try {
proxy.$('Customer Datastore (n8n training)').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('Invalid expression');
expect(exprError.context.type).toEqual('paired_item_no_connection');
done();
}
});
test('$("NodeName").first(), node has no connection to referenced node', (done) => {
const proxy = getProxyFromFixture(
fixture.workflow,
fixture.run,
'Reference impossible with .first()',
);
try {
proxy.$('Impossible').first().json.name;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('no data, execute "Impossible" node first');
expect(exprError.context.type).toEqual('no_node_execution_data');
done();
}
});
test('$json, Node has no connections', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'NoInputConnection');
try {
proxy.$json.email;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('No execution data available');
expect(exprError.context.type).toEqual('no_input_connection');
done();
}
});
test('$("NodeName").item, Node has not run', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Impossible');
try {
proxy.$('Impossible if').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('no data, execute "Impossible if" node first');
expect(exprError.context.type).toEqual('no_node_execution_data');
done();
}
});
test('$json, Node has not run', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Impossible');
try {
proxy.$json.email;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('No execution data available');
expect(exprError.context.type).toEqual('no_execution_data');
done();
}
});
test('$("NodeName").item, paired item error: more than 1 matching item', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'PairedItemMultipleMatches');
try {
proxy.$('Edit Fields').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('Invalid expression');
expect(exprError.context.type).toEqual('paired_item_multiple_matches');
done();
}
});
test('$("NodeName").item, paired item error: missing paired item', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'PairedItemInfoMissing');
try {
proxy.$('Edit Fields').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('Cant get data for expression');
expect(exprError.context.type).toEqual('paired_item_no_info');
done();
}
});
test('$("NodeName").item, paired item error: invalid paired item', (done) => {
const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'IncorrectPairedItem');
try {
proxy.$('Edit Fields').item;
done('should throw');
} catch (error) {
expect(error).toBeInstanceOf(ExpressionError);
const exprError = error as ExpressionError;
expect(exprError.message).toEqual('Cant get data for expression');
expect(exprError.context.type).toEqual('paired_item_invalid_info');
done();
}
});
});
});

View file

@ -0,0 +1,140 @@
{
"data": {
"startData": {},
"resultData": {
"runData": {
"Start": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": {}
}
]
]
},
"source": []
}
],
"Function": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "initialName": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 160 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 121 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 275 },
"pairedItem": { "item": 0 }
},
{
"json": { "initialName": 950 },
"pairedItem": { "item": 0 }
}
]
]
},
"source": [
{
"previousNode": "Start"
}
]
}
],
"Rename": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "data": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "data": 160 },
"pairedItem": { "item": 1 }
},
{
"json": { "data": 121 },
"pairedItem": { "item": 2 }
},
{
"json": { "data": 275 },
"pairedItem": { "item": 3 }
},
{
"json": { "data": 950 },
"pairedItem": { "item": 4 }
}
]
]
},
"source": [
{
"previousNode": "Function"
}
]
}
],
"End": [
{
"startTime": 1,
"executionTime": 1,
"data": {
"main": [
[
{
"json": { "data": 105 },
"pairedItem": { "item": 0 }
},
{
"json": { "data": 160 },
"pairedItem": { "item": 1 }
},
{
"json": { "data": 121 },
"pairedItem": { "item": 2 }
},
{
"json": { "data": 275 },
"pairedItem": { "item": 3 }
},
{
"json": { "data": 950 },
"pairedItem": { "item": 4 }
}
]
]
},
"source": [
{
"previousNode": "Rename"
}
]
}
]
}
}
},
"mode": "manual",
"startedAt": "2024-02-08T15:45:18.848Z",
"stoppedAt": "2024-02-08T15:45:18.862Z",
"status": "running"
}

View file

@ -0,0 +1,86 @@
{
"name": "",
"nodes": [
{
"name": "Start",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-1",
"position": [100, 200]
},
{
"name": "Function",
"type": "test.set",
"parameters": {
"functionCode": "// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require(\"luxon\");\n\nconst data = [\n {\n \"length\": 105\n },\n {\n \"length\": 160\n },\n {\n \"length\": 121\n },\n {\n \"length\": 275\n },\n {\n \"length\": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));"
},
"typeVersion": 1,
"id": "uuid-2",
"position": [280, 200]
},
{
"name": "Rename",
"type": "test.set",
"parameters": {
"value1": "data",
"value2": "initialName"
},
"typeVersion": 1,
"id": "uuid-3",
"position": [460, 200]
},
{
"name": "Set",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-4",
"position": [640, 200]
},
{
"name": "End",
"type": "test.set",
"parameters": {},
"typeVersion": 1,
"id": "uuid-5",
"position": [640, 200]
}
],
"pinData": {},
"connections": {
"Start": {
"main": [
[
{
"node": "Function",
"type": "main",
"index": 0
}
]
]
},
"Function": {
"main": [
[
{
"node": "Rename",
"type": "main",
"index": 0
}
]
]
},
"Rename": {
"main": [
[
{
"node": "End",
"type": "main",
"index": 0
}
]
]
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,747 @@
{
"name": "WorkflowDataProxy errors",
"nodes": [
{
"parameters": {},
"id": "b5122d27-4bb5-4100-a69b-03b1dcac76c7",
"name": "When clicking \"Test workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [740, 1680]
},
{
"parameters": {
"operation": "getAllPeople"
},
"id": "bf471582-900d-47af-848c-2d4218798775",
"name": "Customer Datastore (n8n training)",
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
"typeVersion": 1,
"position": [1180, 1680]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $json.name }}"
}
]
},
"options": {}
},
"id": "1de94b04-c87b-4ef1-b5d7-5078f9e33220",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1400, 1680]
},
{
"parameters": {
"content": "These expression should always be red — there is no way of getting the input data even if you execute. Text should be:",
"height": 349.2762683040461,
"width": 339
},
"id": "c277f7c6-8a7a-41e9-9484-78e90bd205bf",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1020, 1040]
},
{
"parameters": {
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "name"
}
]
},
"options": {}
},
"id": "f6606ff5-4d66-4efb-8dad-de7662f20867",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [1820, 860]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "71fbae4a-f5b3-4db1-9684-83c4d2037099",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 760]
},
{
"parameters": {
"content": "[No path back to node]",
"height": 209,
"width": 150
},
"id": "24e878cb-a681-4c00-bec1-83188aa20eb7",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1020, 1132]
},
{
"parameters": {
"content": "[No input connected]",
"height": 201,
"width": 150
},
"id": "4bd26f55-87b5-4ad1-b3f1-ae2786941114",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1200, 1132]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];"
},
"id": "6538818e-c5b3-422b-920c-d5d52533578b",
"name": "Break pairedItem chain",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1120]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "42641e54-60e1-46d7-bcb4-b55a83f89f6b",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1020]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": 99\n }\n];"
},
"id": "05583883-ab4a-42c2-9edb-8e8cf3c9d074",
"name": "Incorrect pairedItem info",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1680]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "aea58e9e-5a00-4a86-a0bc-b077a07cd1f4",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1580]
},
{
"parameters": {
"content": "If the pinned node is executed, make grey and use text:\n[For preview, unpin node <node_name> and execute]",
"height": 255,
"width": 237.63786881219818
},
"id": "3fdf6bdc-8065-421b-9ecf-6453946356a4",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1840]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": [1, 2, 3, 4]\n }\n];"
},
"id": "f8de7b0a-79c1-4b7a-a183-feb94f2f8625",
"name": "Multiple matching items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 2200]
},
{
"parameters": {
"content": "This error should be\n\n[Can't determine which item to use]",
"height": 255,
"width": 177
},
"id": "601c050a-7909-4708-be8d-4de248b68392",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 2100]
},
{
"parameters": {
"content": "This should be grey, with text\n\n[For preview, unpin node <node_name> and execute]",
"height": 291.70186796527776,
"width": 177
},
"id": "dfdcfaf4-a76b-4307-97a6-3fd7772e9fa8",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 2360]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": [1, 2, 3, 4]\n }\n];"
},
"id": "8f2a9642-68e7-4dc6-a6c2-2018919327a3",
"name": "Multiple matching items, pinned",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 2500]
},
{
"parameters": {
"content": "If the pinned node isn't executed (e.g. if you execute one of the other code nodes in the same column), the expression is green!",
"height": 128.93706220621976,
"width": 177
},
"id": "65cf9b4c-a96d-46f5-b9bb-f6d88d1fbc44",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2220, 1940]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"json\": {\n \"field\": \"the same\"\n },\n \"pairedItem\": 99\n }\n];"
},
"id": "0bdfe0d2-7de2-472d-bc0a-2d0eff0e08c7",
"name": "Incorrect pairedItem info, pinned1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1940]
},
{
"parameters": {
"jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];"
},
"id": "b080a98e-d983-414a-b925-bdfc7ab2c3b6",
"name": "Break pairedItem chain, pinned",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1820, 1420]
},
{
"parameters": {
"content": "This should be grey, with text\n\n[For preview, unpin node <node_name> and execute]",
"height": 291.70186796527776,
"width": 177
},
"id": "ce083193-1944-4c6c-925d-9e23c5194d98",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [2000, 1280]
},
{
"parameters": {
"content": "We should also change the output pane error on execution in this case.\n\nERROR: No path back to '<node_name>' node\nDescription: Please make sure it is connected to this node (there can be other nodes in between)",
"height": 209,
"width": 301.59467203049536
},
"id": "755e07f0-3f18-4b08-ad30-79221a76507a",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1080, 1360]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "1fff886f-3d13-4fbf-b0fb-7e2f845937c0",
"leftValue": "={{ false }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "56dd65f0-d67a-42ce-a876-77434f621dc3",
"name": "Impossible if",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1000, 2000]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "test",
"stringValue": "xzy"
}
]
},
"options": {}
},
"id": "11eadfc8-d14d-407c-b6d5-6e59b2e427a1",
"name": "Impossible",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1180, 1980]
},
{
"parameters": {
"content": "Should be an error when using .item:\n\n[No path back to node]",
"height": 237.7232010163043,
"width": 150
},
"id": "c3a3fdc2-66fa-4562-a359-45bdece2f625",
"name": "Sticky Note12",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1400, 1880]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $('Impossible').item.json.name }}"
}
]
},
"options": {}
},
"id": "4cbbee96-dd4c-4625-95b9-c68faef3e9a8",
"name": "Reference impossible with .item",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1420, 2000]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "name",
"stringValue": "={{ $('Impossible').first().json.name }}"
}
]
},
"options": {}
},
"id": "6d47bd08-810a-4ade-be57-635adc1df47f",
"name": "Reference impossible with .first()",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1420, 2320]
},
{
"parameters": {
"content": "When using .first(), .last() or .all() and the node isn't executed, show grey warning:\n\n[Execute <node_name> for preview]",
"height": 330.27573762439613,
"width": 229.78666948973432
},
"id": "1fcf2562-0789-41ad-8c92-44bcdd5d44e6",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [1400, 2180]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('non existent') }}"
}
]
},
"options": {}
},
"id": "327d7f7b-61a5-4d60-9542-d61f84e7c83a",
"name": "Reference non-existent node",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1000, 2320]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Customer Datastore (n8n training)').item.json.email }}"
}
]
},
"options": {}
},
"id": "38e3a736-4e13-4c23-af16-e50e605c4fb5",
"name": "NoPathBack",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1040, 1184]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $json.email }}"
}
]
},
"options": {}
},
"id": "2a7eaf81-6d64-488d-baf6-cc2f962908af",
"name": "NoInputConnection",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [1220, 1180]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "166ee813-1db8-43a6-ace4-990c41dfeaea",
"name": "PairedItemInfoMissing",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1120]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "a2dca54c-03ef-4a16-bf29-71eb0012cf0b",
"name": "PairedItemInfoMissingPinned",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1420]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "0a1f566b-8dcf-4e28-81c4-faeadcdc02fb",
"name": "IncorrectPairedItem",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1680]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.to }}"
}
]
},
"options": {}
},
"id": "4d76b75f-5896-48ba-bb2f-8a2574ec1b8b",
"name": "IncorrectPairedItemPinned",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 1940]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "c4636b5c-c13a-441b-a59c-23962b2757b3",
"name": "PairedItemMultipleMatches2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 2200]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "6d687cf8-5309-4d44-aab3-aa023a42fa27",
"name": "PairedItemMultipleMatches",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 860]
},
{
"parameters": {
"fields": {
"values": [
{
"stringValue": "={{ $('Edit Fields').item.json.name }}"
}
]
},
"options": {}
},
"id": "d87a7aa4-b4c7-4fad-897d-a7ce0657bef3",
"name": "IncorrectPairedItemPinned2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [2040, 2500]
}
],
"pinData": {
"Multiple matching items, pinned": [
{
"json": {
"field": "the same"
}
}
],
"Incorrect pairedItem info, pinned1": [
{
"json": {
"field": "the same"
}
}
],
"Break pairedItem chain, pinned": [
{
"json": {
"field": "the same"
}
}
]
},
"connections": {
"When clicking \"Test workflow\"": {
"main": [
[
{
"node": "Customer Datastore (n8n training)",
"type": "main",
"index": 0
},
{
"node": "Impossible if",
"type": "main",
"index": 0
},
{
"node": "Reference non-existent node",
"type": "main",
"index": 0
}
]
]
},
"Customer Datastore (n8n training)": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
},
{
"node": "Reference impossible with .item",
"type": "main",
"index": 0
},
{
"node": "Reference impossible with .first()",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
},
{
"node": "Break pairedItem chain",
"type": "main",
"index": 0
},
{
"node": "Incorrect pairedItem info",
"type": "main",
"index": 0
},
{
"node": "Multiple matching items",
"type": "main",
"index": 0
},
{
"node": "Incorrect pairedItem info, pinned1",
"type": "main",
"index": 0
},
{
"node": "Multiple matching items, pinned",
"type": "main",
"index": 0
},
{
"node": "Break pairedItem chain, pinned",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "PairedItemMultipleMatches",
"type": "main",
"index": 0
}
]
]
},
"Break pairedItem chain": {
"main": [
[
{
"node": "PairedItemInfoMissing",
"type": "main",
"index": 0
}
]
]
},
"Incorrect pairedItem info": {
"main": [
[
{
"node": "IncorrectPairedItem",
"type": "main",
"index": 0
}
]
]
},
"Multiple matching items": {
"main": [
[
{
"node": "PairedItemMultipleMatches2",
"type": "main",
"index": 0
}
]
]
},
"Multiple matching items, pinned": {
"main": [
[
{
"node": "IncorrectPairedItemPinned2",
"type": "main",
"index": 0
}
]
]
},
"Incorrect pairedItem info, pinned1": {
"main": [
[
{
"node": "IncorrectPairedItemPinned",
"type": "main",
"index": 0
}
]
]
},
"Break pairedItem chain, pinned": {
"main": [
[
{
"node": "PairedItemInfoMissingPinned",
"type": "main",
"index": 0
}
]
]
},
"Impossible if": {
"main": [
[
{
"node": "Impossible",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "f6276c80-c1d1-485b-9d07-894868bcd701",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "2e88d456a76a9edc44cbcda082bb44ddef9555356ef691b0c6a45099d5095a45"
},
"id": "BmXv9neCtTggKXuG",
"tags": []
}