fix(editor): Use pinned data to resolve expressions in unexecuted nodes (#9693)

Co-authored-by: Milorad Filipovic <milorad@n8n.io>
Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
Iván Ovejero 2024-06-27 10:49:53 +02:00 committed by GitHub
parent e995309789
commit 6cb3072a5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 561 additions and 54 deletions

View file

@ -0,0 +1,135 @@
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2111 expressions should support pinned data', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('supports pinned data in expressions unexecuted and executed parent nodes', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
// test can resolve correctly based on item
ndv.actions.switchInputMode('Table');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// test previous node executed
ndv.actions.execute();
ndv.getters.inputTableRow(1).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// check it resolved correctly on the backend
ndv.getters
.outputTbodyCell(1, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(2, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(1, 1)
.should('contain.text', '0,0\\nJoe\\n\\nJoe\\n\\nJoe\\n\\nJoe\\nJoe');
ndv.getters
.outputTbodyCell(2, 1)
.should('contain.text', '0,1\\nJoan\\n\\nJoan\\n\\nJoan\\n\\nJoan\\nJoan');
});
it('resets expressions after node is unpinned', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.actions.close();
// unpin pinned node
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('exist');
workflowPage.getters.canvasNodeByName('PinnedSet').eq(0).click();
workflowPage.actions.hitPinNodeShortcut();
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('not.exist');
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters.nodeParameters().find('parameter-expression-preview-value').should('not.exist');
ndv.getters.parameterInput('value').eq(0).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][undefined]',
);
// close open expression
ndv.getters.inputLabel().eq(0).click();
ndv.getters.parameterInput('value').eq(1).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'0,0[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][Execute previous nodes for preview]',
);
});
});

View file

@ -0,0 +1,112 @@
{
"meta": {
"instanceId": "5bd32b91ed2a88e542012920460f736c3687a32fbb953718f6952d182231c0ff"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a482f1fd-4815-4da4-a733-7beafb43c500",
"name": "static",
"value": "={{ $('PinnedSet').first().json.firstName }}\n{{ $('PinnedSet').itemMatching(0).json.firstName }}\n{{ $('PinnedSet').itemMatching(1).json.firstName }}\n{{ $('PinnedSet').last().json.firstName }}\n{{ $('PinnedSet').all()[0].json.firstName }}\n{{ $('PinnedSet').all()[1].json.firstName }}\n\n{{ $input.first().json.firstName }}\n{{ $input.last().json.firstName }}\n\n{{ $items()[0].json.firstName }}",
"type": "string"
},
{
"id": "2c973f2a-7ca0-41bc-903c-7174bee251b0",
"name": "variable",
"value": "={{ $runIndex }},{{ $itemIndex }}\n{{ $node['PinnedSet'].json.firstName }}\n\n{{ $('PinnedSet').item.json.firstName }}\n\n{{ $input.item.json.firstName }}\n\n{{ $json.firstName }}\n{{ $data.firstName }}",
"type": "string"
}
]
},
"options": {}
},
"id": "ac55ee16-4598-48bf-ace3-a48fed1d4ff3",
"name": "NotPinnedWithExpressions",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1600,
640
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3058c300-b377-41b7-9c90-a01372f9b581",
"name": "firstName",
"value": "Joe",
"type": "string"
},
{
"id": "bb871662-c23c-4234-ac0c-b78c279bbf34",
"name": "lastName",
"value": "Smith",
"type": "string"
}
]
},
"options": {}
},
"id": "300a3888-cc2f-4e61-8578-b0adbcf33450",
"name": "PinnedSet",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1340,
640
]
},
{
"parameters": {},
"id": "426ff39a-3408-48b4-899f-60db732675f8",
"name": "Start",
"type": "n8n-nodes-base.manualTrigger",
"position": [
1100,
640
],
"typeVersion": 1
}
],
"connections": {
"PinnedSet": {
"main": [
[
{
"node": "NotPinnedWithExpressions",
"type": "main",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "PinnedSet",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"PinnedSet": [
{
"firstName": "Joe",
"lastName": "Smith"
},
{
"firstName": "Joan",
"lastName": "Summers"
}
]
}
}

View file

@ -27,6 +27,7 @@ export class NDV extends BasePage {
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'), nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
savePinnedDataButton: () => savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'), this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
inputLabel: () => cy.getByTestId('input-label'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'), outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'), outputTableHeaders: () => this.getters.outputDataContainer().find('table thead th'),
outputTableHeaderByText: (text: string) => this.getters.outputTableHeaders().contains(text), outputTableHeaderByText: (text: string) => this.getters.outputTableHeaders().contains(text),

View file

@ -218,7 +218,6 @@ export function resolveParameter<T = IDataObject>(
ExpressionEvaluatorProxy.setEvaluator( ExpressionEvaluatorProxy.setEvaluator(
useSettingsStore().settings.expressions?.evaluator ?? 'tmpl', useSettingsStore().settings.expressions?.evaluator ?? 'tmpl',
); );
return workflow.expression.getParameterValue( return workflow.expression.getParameterValue(
parameter, parameter,
runExecutionData, runExecutionData,
@ -342,39 +341,6 @@ function connectionInputData(
} }
} }
const workflowsStore = useWorkflowsStore();
if (workflowsStore.shouldReplaceInputDataWithPinData) {
const parentPinData = parentNode.reduce<INodeExecutionData[]>((acc, parentNodeName, index) => {
const pinData = workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
}
return connectionInputData; return connectionInputData;
} }

View file

@ -362,7 +362,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
function getCurrentWorkflow(copyData?: boolean): Workflow { function getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = getNodes(); const nodes = getNodes();
const connections = allConnections.value; const connections = allConnections.value;
const cacheKey = JSON.stringify({ nodes, connections }); const cacheKey = JSON.stringify({ nodes, connections, pinData: pinnedWorkflowData.value });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) { if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow; return cachedWorkflow;
} }

View file

@ -415,7 +415,7 @@ export class Workflow {
* *
* @param {string} nodeName Name of the node to return the pinData of * @param {string} nodeName Name of the node to return the pinData of
*/ */
getPinDataOfNode(nodeName: string): IDataObject[] | undefined { getPinDataOfNode(nodeName: string): INodeExecutionData[] | undefined {
return this.pinData ? this.pinData[nodeName] : undefined; return this.pinData ? this.pinData[nodeName] : undefined;
} }

View file

@ -29,6 +29,7 @@ import { deepCopy } from './utils';
import { getGlobalState } from './GlobalState'; import { getGlobalState } from './GlobalState';
import { ApplicationError } from './errors/application.error'; import { ApplicationError } from './errors/application.error';
import { SCRIPTING_NODE_TYPES } from './Constants'; import { SCRIPTING_NODE_TYPES } from './Constants';
import { getPinDataIfManualExecution } from './WorkflowDataProxyHelpers';
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean( return Boolean(
@ -241,6 +242,29 @@ export class WorkflowDataProxy {
}); });
} }
private getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
shortSyntax = false,
}: {
nodeName: string;
branchIndex?: number;
runIndex?: number;
shortSyntax?: boolean;
}) {
try {
return this.getNodeExecutionData(nodeName, shortSyntax, branchIndex, runIndex);
} catch (e) {
const pinData = getPinDataIfManualExecution(this.workflow, nodeName, this.mode);
if (pinData) {
return pinData;
}
throw e;
}
}
/** /**
* Returns the node ExecutionData * Returns the node ExecutionData
* *
@ -283,7 +307,7 @@ export class WorkflowDataProxy {
if ( if (
!that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) && !that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName) !getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
) { ) {
throw new ExpressionError('Referenced node is unexecuted', { throw new ExpressionError('Referenced node is unexecuted', {
runIndex: that.runIndex, runIndex: that.runIndex,
@ -383,7 +407,10 @@ export class WorkflowDataProxy {
} }
if (['binary', 'data', 'json'].includes(name)) { if (['binary', 'data', 'json'].includes(name)) {
const executionData = that.getNodeExecutionData(nodeName, shortSyntax, undefined); const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
shortSyntax,
});
if (executionData.length === 0) { if (executionData.length === 0) {
if (that.workflow.getParentNodes(nodeName).length === 0) { if (that.workflow.getParentNodes(nodeName).length === 0) {
@ -619,11 +646,6 @@ export class WorkflowDataProxy {
getDataProxy(): IWorkflowDataProxyData { getDataProxy(): IWorkflowDataProxyData {
const that = this; const that = this;
const getNodeOutput = (nodeName: string, branchIndex: number, runIndex?: number) => {
runIndex = runIndex === undefined ? -1 : runIndex;
return that.getNodeExecutionData(nodeName, false, branchIndex, runIndex);
};
// replacing proxies with the actual data. // replacing proxies with the actual data.
const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => { const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => {
if (typeof data !== 'object' || typeof query !== 'string') { if (typeof data !== 'object' || typeof query !== 'string') {
@ -662,7 +684,7 @@ export class WorkflowDataProxy {
if (context?.nodeCause) { if (context?.nodeCause) {
const nodeName = context.nodeCause; const nodeName = context.nodeCause;
const pinData = this.workflow.getPinDataOfNode(nodeName); const pinData = getPinDataIfManualExecution(that.workflow, nodeName, that.mode);
if (pinData) { if (pinData) {
if (!context) { if (!context) {
@ -776,7 +798,8 @@ export class WorkflowDataProxy {
const previousNodeOutputData = const previousNodeOutputData =
taskData?.data?.main?.[previousNodeOutput] ?? taskData?.data?.main?.[previousNodeOutput] ??
(that.workflow.getPinDataOfNode(sourceData.previousNode) as INodeExecutionData[]); getPinDataIfManualExecution(that.workflow, sourceData.previousNode, that.mode) ??
[];
const source = taskData?.source ?? []; const source = taskData?.source ?? [];
if (pairedItem.item >= previousNodeOutputData.length) { if (pairedItem.item >= previousNodeOutputData.length) {
@ -897,10 +920,22 @@ export class WorkflowDataProxy {
} }
taskData = taskData =
that.runExecutionData!.resultData.runData[sourceData.previousNode][ that.runExecutionData!.resultData.runData[sourceData.previousNode]?.[
sourceData?.previousNodeRun || 0 sourceData?.previousNodeRun || 0
]; ];
if (!taskData) {
const pinData = getPinDataIfManualExecution(
that.workflow,
sourceData.previousNode,
that.mode,
);
if (pinData) {
taskData = { data: { main: [pinData] }, startTime: 0, executionTime: 0, source: [] };
}
}
const previousNodeOutput = sourceData.previousNodeOutput || 0; const previousNodeOutput = sourceData.previousNodeOutput || 0;
if (previousNodeOutput >= taskData.data!.main.length) { if (previousNodeOutput >= taskData.data!.main.length) {
throw createExpressionError('Cant get data for expression', { throw createExpressionError('Cant get data for expression', {
@ -944,7 +979,7 @@ export class WorkflowDataProxy {
const ensureNodeExecutionData = () => { const ensureNodeExecutionData = () => {
if ( if (
!that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) && !that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) &&
!that.workflow.getPinDataOfNode(nodeName) !getPinDataIfManualExecution(that.workflow, nodeName, that.mode)
) { ) {
throw createExpressionError('Referenced node is unexecuted', { throw createExpressionError('Referenced node is unexecuted', {
runIndex: that.runIndex, runIndex: that.runIndex,
@ -1009,8 +1044,20 @@ export class WorkflowDataProxy {
itemIndex = that.itemIndex; itemIndex = that.itemIndex;
} }
if (!that.connectionInputData.length) {
const pinnedData = getPinDataIfManualExecution(
that.workflow,
nodeName,
that.mode,
);
if (pinnedData) {
return pinnedData[itemIndex];
}
}
const executionData = that.connectionInputData; const executionData = that.connectionInputData;
const input = executionData[itemIndex]; const input = executionData?.[itemIndex];
if (!input) { if (!input) {
throw createExpressionError('Cant get data for expression', { throw createExpressionError('Cant get data for expression', {
messageTemplate: 'Cant get data for expression under %%PARAMETER%% field', messageTemplate: 'Cant get data for expression under %%PARAMETER%% field',
@ -1061,6 +1108,7 @@ export class WorkflowDataProxy {
} }
return pairedItemMethod; return pairedItemMethod;
} }
if (property === 'first') { if (property === 'first') {
ensureNodeExecutionData(); ensureNodeExecutionData();
return (branchIndex?: number, runIndex?: number) => { return (branchIndex?: number, runIndex?: number) => {
@ -1070,7 +1118,11 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName) that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ?? ?.sourceIndex ??
0; 0;
const executionData = getNodeOutput(nodeName, branchIndex, runIndex); const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
});
if (executionData[0]) return executionData[0]; if (executionData[0]) return executionData[0];
return undefined; return undefined;
}; };
@ -1084,7 +1136,11 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName) that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ?? ?.sourceIndex ??
0; 0;
const executionData = getNodeOutput(nodeName, branchIndex, runIndex); const executionData = that.getNodeExecutionOrPinnedData({
nodeName,
branchIndex,
runIndex,
});
if (!executionData.length) return undefined; if (!executionData.length) return undefined;
if (executionData[executionData.length - 1]) { if (executionData[executionData.length - 1]) {
return executionData[executionData.length - 1]; return executionData[executionData.length - 1];
@ -1101,7 +1157,7 @@ export class WorkflowDataProxy {
that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName) that.workflow.getNodeConnectionIndexes(that.activeNodeName, nodeName)
?.sourceIndex ?? ?.sourceIndex ??
0; 0;
return getNodeOutput(nodeName, branchIndex, runIndex); return that.getNodeExecutionOrPinnedData({ nodeName, branchIndex, runIndex });
}; };
} }
if (property === 'context') { if (property === 'context') {

View file

@ -0,0 +1,12 @@
import type { INodeExecutionData, Workflow, WorkflowExecuteMode } from '.';
export function getPinDataIfManualExecution(
workflow: Workflow,
nodeName: string,
mode: WorkflowExecuteMode,
): INodeExecutionData[] | undefined {
if (mode !== 'manual') {
return undefined;
}
return workflow.getPinDataOfNode(nodeName);
}

View file

@ -571,6 +571,37 @@ const setNode: LoadedClass<INodeType> = {
}, },
}; };
const manualTriggerNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'Manual Trigger',
name: 'n8n-nodes-base.manualTrigger',
icon: 'fa:mouse-pointer',
group: ['trigger'],
version: 1,
description: 'Runs the flow on clicking a button in n8n',
eventTriggerDescription: '',
maxNodes: 1,
defaults: {
name: 'When clicking Test workflow',
color: '#909298',
},
inputs: [],
outputs: ['main'],
properties: [
{
displayName:
'This node is where the workflow execution starts (when you click the test button on the canvas).<br><br> <a data-action="showNodeCreator">Explore other ways to trigger your workflow</a> (e.g on a schedule, or a webhook)',
name: 'notice',
type: 'notice',
default: '',
},
],
},
},
};
export class NodeTypes implements INodeTypes { export class NodeTypes implements INodeTypes {
nodeTypes: INodeTypeData = { nodeTypes: INodeTypeData = {
'n8n-nodes-base.stickyNote': stickyNode, 'n8n-nodes-base.stickyNote': stickyNode,
@ -628,6 +659,7 @@ export class NodeTypes implements INodeTypes {
}, },
}, },
}, },
'n8n-nodes-base.manualTrigger': manualTriggerNode,
}; };
getByName(nodeType: string): INodeType | IVersionedNodeType { getByName(nodeType: string): INodeType | IVersionedNodeType {

View file

@ -1,4 +1,11 @@
import type { IExecuteData, INode, IRun, IWorkflowBase } from '@/Interfaces'; import type {
IExecuteData,
INode,
IPinData,
IRun,
IWorkflowBase,
WorkflowExecuteMode,
} from '@/Interfaces';
import { Workflow } from '@/Workflow'; import { Workflow } from '@/Workflow';
import { WorkflowDataProxy } from '@/WorkflowDataProxy'; import { WorkflowDataProxy } from '@/WorkflowDataProxy';
import { ExpressionError } from '@/errors/expression.error'; import { ExpressionError } from '@/errors/expression.error';
@ -13,7 +20,12 @@ const loadFixture = (fixture: string) => {
return { workflow, run }; return { workflow, run };
}; };
const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNode: string) => { const getProxyFromFixture = (
workflow: IWorkflowBase,
run: IRun | null,
activeNode: string,
mode?: WorkflowExecuteMode,
) => {
const taskData = run?.data.resultData.runData[activeNode]?.[0]; const taskData = run?.data.resultData.runData[activeNode]?.[0];
const lastNodeConnectionInputData = taskData?.data?.main[0]; const lastNodeConnectionInputData = taskData?.data?.main[0];
@ -29,6 +41,16 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
}; };
} }
let pinData: IPinData = {};
if (workflow.pinData) {
// json key is stored as part of workflow
// but dropped when copy/pasting
// so adding here to keep updating tests simple
for (let nodeName in workflow.pinData) {
pinData[nodeName] = workflow.pinData[nodeName].map((item) => ({ json: item }));
}
}
const dataProxy = new WorkflowDataProxy( const dataProxy = new WorkflowDataProxy(
new Workflow({ new Workflow({
id: '123', id: '123',
@ -37,6 +59,7 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
connections: workflow.connections, connections: workflow.connections,
active: false, active: false,
nodeTypes: Helpers.NodeTypes(), nodeTypes: Helpers.NodeTypes(),
pinData,
}), }),
run?.data ?? null, run?.data ?? null,
0, 0,
@ -44,7 +67,7 @@ const getProxyFromFixture = (workflow: IWorkflowBase, run: IRun | null, activeNo
activeNode, activeNode,
lastNodeConnectionInputData ?? [], lastNodeConnectionInputData ?? [],
{}, {},
'manual', mode ?? 'integrated',
{}, {},
executeData, executeData,
); );
@ -323,4 +346,61 @@ describe('WorkflowDataProxy', () => {
} }
}); });
}); });
describe('Pinned data with manual execution', () => {
const fixture = loadFixture('pindata');
const proxy = getProxyFromFixture(fixture.workflow, null, 'NotPinnedSet1', 'manual');
test('$(PinnedSet).item.json', () => {
expect(proxy.$('PinnedSet').item.json).toEqual({ firstName: 'Joe', lastName: 'Smith' });
});
test('$(PinnedSet).item.json.firstName', () => {
expect(proxy.$('PinnedSet').item.json.firstName).toBe('Joe');
});
test('$(PinnedSet).pairedItem().json.firstName', () => {
expect(proxy.$('PinnedSet').pairedItem().json.firstName).toBe('Joe');
});
test('$(PinnedSet).first().json.firstName', () => {
expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe');
});
test('$(PinnedSet).first().json.firstName', () => {
expect(proxy.$('PinnedSet').first().json.firstName).toBe('Joe');
});
test('$(PinnedSet).last().json.firstName', () => {
expect(proxy.$('PinnedSet').last().json.firstName).toBe('Joan');
});
test('$(PinnedSet).all()[0].json.firstName', () => {
expect(proxy.$('PinnedSet').all()[0].json.firstName).toBe('Joe');
});
test('$(PinnedSet).all()[1].json.firstName', () => {
expect(proxy.$('PinnedSet').all()[1].json.firstName).toBe('Joan');
});
test('$(PinnedSet).all()[2]', () => {
expect(proxy.$('PinnedSet').all()[2]).toBeUndefined();
});
test('$(PinnedSet).itemMatching(0).json.firstName', () => {
expect(proxy.$('PinnedSet').itemMatching(0).json.firstName).toBe('Joe');
});
test('$(PinnedSet).itemMatching(1).json.firstName', () => {
expect(proxy.$('PinnedSet').itemMatching(1).json.firstName).toBe('Joan');
});
test('$(PinnedSet).itemMatching(2)', () => {
expect(proxy.$('PinnedSet').itemMatching(2)).toBeUndefined();
});
test('$node[PinnedSet].json.firstName', () => {
expect(proxy.$node.PinnedSet.json.firstName).toBe('Joe');
});
});
}); });

View file

@ -0,0 +1,7 @@
{
"data": {
"resultData": {
"runData": {}
}
}
}

View file

@ -0,0 +1,106 @@
{
"meta": {
"instanceId": "a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3058c300-b377-41b7-9c90-a01372f9b581",
"name": "firstName",
"value": "Joe",
"type": "string"
},
{
"id": "bb871662-c23c-4234-ac0c-b78c279bbf34",
"name": "lastName",
"value": "Smith",
"type": "string"
}
]
},
"options": {}
},
"id": "baee2bf4-5083-4cbe-8e51-4eddcf859ef5",
"name": "PinnedSet",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1120,
380
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a482f1fd-4815-4da4-a733-7beafb43c500",
"name": "test",
"value": "={{ $('PinnedSet').all().json }}\n{{ $('PinnedSet').item.json.firstName }}\n{{ $('PinnedSet').first().json.firstName }}\n{{ $('PinnedSet').itemMatching(0).json.firstName }}\n{{ $('PinnedSet').itemMatching(1).json.firstName }}\n{{ $('PinnedSet').last().json.firstName }}\n{{ $('PinnedSet').all()[0].json.firstName }}\n{{ $('PinnedSet').all()[1].json.firstName }}\n\n{{ $input.first().json.firstName }}\n{{ $input.last().json.firstName }}\n{{ $input.item.json.firstName }}\n\n{{ $json.firstName }}\n{{ $data.firstName }}\n\n{{ $items()[0].json.firstName }}",
"type": "string"
}
]
},
"options": {}
},
"id": "2a543169-e2c1-4764-ac63-09534310b2b9",
"name": "NotPinnedSet1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1360,
380
]
},
{
"parameters": {},
"id": "f36672e5-8c87-480e-a5b8-de9da6b63192",
"name": "Start",
"type": "n8n-nodes-base.manualTrigger",
"position": [
920,
380
],
"typeVersion": 1
}
],
"connections": {
"PinnedSet": {
"main": [
[
{
"node": "NotPinnedSet1",
"type": "main",
"index": 0
}
]
]
},
"Start": {
"main": [
[
{
"node": "PinnedSet",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"PinnedSet": [
{
"firstName": "Joe",
"lastName": "Smith"
},
{
"firstName": "Joan",
"lastName": "Summers"
}
]
}
}