feat(n8n Evaluation Trigger Node): Add Evaluation Trigger and Evaluation Node (#15194)

This commit is contained in:
Dana
2025-05-16 11:16:00 +02:00
committed by GitHub
parent 840a3bee4b
commit 570d1e7aad
24 changed files with 1778 additions and 6 deletions

View File

@@ -275,7 +275,7 @@ describe('Workflow Executions', () => {
cy.getByTestId('node-creator-item-name')
.should('be.visible')
.filter(':contains("Trigger")')
.filter(':contains("Trigger manually")')
.click();
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.executionsSidebar().should('be.visible');

View File

@@ -76,7 +76,7 @@ describe('NodesListPanel', () => {
await fireEvent.click(container.querySelector('.backButton')!);
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
});
it('should render regular nodes', async () => {

View File

@@ -132,3 +132,63 @@ exports[`viewsData > AIView > should return ai view without ai transform node if
"value": "AI",
}
`;
exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled and node is not in the list 1`] = `
{
"items": [
{
"key": "ai_templates_root",
"properties": {
"description": "See what's possible and get started 5x faster",
"icon": "box-open",
"name": "ai_templates_root",
"tag": {
"text": "Recommended",
"type": "info",
},
"title": "AI Templates",
"url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI",
},
"type": "link",
},
{
"key": "agent",
"properties": {
"description": "example mock agent node",
"displayName": "agent",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "agent",
"title": "agent",
},
"type": "node",
},
{
"key": "chain",
"properties": {
"description": "example mock chain node",
"displayName": "chain",
"group": [],
"icon": "fa:pen",
"iconUrl": "nodes/test-node/icon.svg",
"name": "chain",
"title": "chain",
},
"type": "node",
},
{
"key": "AI Other",
"properties": {
"description": "Embeddings, Vector Stores, LLMs and other AI nodes",
"icon": "robot",
"title": "Other AI Nodes",
},
"type": "view",
},
],
"subtitle": "Select an AI Node to add to your workflow",
"title": "AI Nodes",
"value": "AI",
}
`;

View File

@@ -4,6 +4,7 @@ import {
AI_CATEGORY_TOOLS,
AI_SUBCATEGORY,
CUSTOM_API_CALL_KEY,
EVALUATION_TRIGGER,
HTTP_REQUEST_NODE_TYPE,
} from '@/constants';
import { memoize, startCase } from 'lodash-es';
@@ -19,6 +20,7 @@ import { i18n } from '@/plugins/i18n';
import { getCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import { formatTriggerActionName } from '../utils';
import { usePostHog } from '@/stores/posthog.store';
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
@@ -330,7 +332,23 @@ export function useActionsGenerator() {
nodeTypes: INodeTypeDescription[],
httpOnlyCredentials: ICredentialType[],
) {
const visibleNodeTypes = [...nodeTypes];
const posthogStore = usePostHog();
const isEvaluationVariantEnabled = posthogStore.isVariantEnabled(
EVALUATION_TRIGGER.name,
EVALUATION_TRIGGER.variant,
);
const visibleNodeTypes = nodeTypes.filter((node) => {
if (isEvaluationVariantEnabled) {
return true;
}
return (
node.name !== 'n8n-nodes-base.evaluation' &&
node.name !== 'n8n-nodes-base.evaluationTrigger'
);
});
const actions: ActionsRecord<typeof mergedNodes> = {};
const mergedNodes: SimplifiedNodeType[] = [];
visibleNodeTypes

View File

@@ -1,5 +1,10 @@
import { NodeConnectionTypes, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow';
import { useActionsGenerator } from './composables/useActionsGeneration';
import { usePostHog } from '@/stores/posthog.store';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
let posthogStore: ReturnType<typeof usePostHog>;
describe('useActionsGenerator', () => {
const { generateMergedNodesAndActions } = useActionsGenerator();
@@ -19,6 +24,17 @@ describe('useActionsGenerator', () => {
properties: [],
};
beforeEach(() => {
vi.clearAllMocks();
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
posthogStore = usePostHog();
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true);
});
describe('App actions for resource category', () => {
const resourcePropertyWithUser: INodeProperties = {
displayName: 'Resource',
@@ -386,5 +402,48 @@ describe('useActionsGenerator', () => {
],
});
});
it('should not return evaluation or evaluation trigger node if variant is not enabled', () => {
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(false);
const node: INodeTypeDescription = {
...baseV2NodeWoProps,
properties: [
resourcePropertyWithUser,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {},
options: [
{
name: 'Get',
value: 'get',
description: 'Get description',
},
],
default: 'get',
},
],
};
const evalNode: INodeTypeDescription = {
...baseV2NodeWoProps,
name: 'n8n-nodes-base.evaluation',
};
const evalNodeTrigger: INodeTypeDescription = {
...baseV2NodeWoProps,
name: 'n8n-nodes-base.evaluationTrigger',
};
const { mergedNodes } = generateMergedNodesAndActions([node, evalNode, evalNodeTrigger], []);
mergedNodes.forEach((mergedNode) => {
expect(mergedNode.name).not.toEqual('n8n-nodes-base.evaluation');
expect(mergedNode.name).not.toEqual('n8n-nodes-base.evaluationTrigger');
});
});
});
});

View File

@@ -7,6 +7,9 @@ import { useSettingsStore } from '@/stores/settings.store';
import { AIView } from './viewsData';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
import { useTemplatesStore } from '@/stores/templates.store';
import { usePostHog } from '@/stores/posthog.store';
let posthogStore: ReturnType<typeof usePostHog>;
const getNodeType = vi.fn();
@@ -51,6 +54,9 @@ describe('viewsData', () => {
beforeAll(() => {
setActivePinia(createTestingPinia());
posthogStore = usePostHog();
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true);
const templatesStore = useTemplatesStore();
vi.spyOn(templatesStore, 'websiteTemplateRepositoryParameters', 'get').mockImplementation(
@@ -86,5 +92,14 @@ describe('viewsData', () => {
expect(AIView([])).toMatchSnapshot();
});
test('should return ai view without ai transform node if ask ai is not enabled and node is not in the list', () => {
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(false);
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false);
expect(AIView([])).toMatchSnapshot();
});
});
});

View File

@@ -57,17 +57,19 @@ import {
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
HUMAN_IN_THE_LOOP_CATEGORY,
EVALUATION_TRIGGER,
} from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { SimplifiedNodeType } from '@/Interface';
import type { INodeTypeDescription, Themed } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { EVALUATION_TRIGGER_NODE_TYPE, NodeConnectionTypes } from 'n8n-workflow';
import type { NodeConnectionType } from 'n8n-workflow';
import { useTemplatesStore } from '@/stores/templates.store';
import type { BaseTextKey } from '@/plugins/i18n';
import { camelCase } from 'lodash-es';
import { useSettingsStore } from '@/stores/settings.store';
import { usePostHog } from '@/stores/posthog.store';
export interface NodeViewItemSection {
key: string;
@@ -141,6 +143,16 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const templatesStore = useTemplatesStore();
const posthogStore = usePostHog();
const isEvaluationVariantEnabled = posthogStore.isVariantEnabled(
EVALUATION_TRIGGER.name,
EVALUATION_TRIGGER.variant,
);
const evaluationNodeStore = nodeTypesStore.getNodeType('n8n-nodes-base.evaluation');
const evaluationNode =
isEvaluationVariantEnabled && evaluationNodeStore ? [getNodeView(evaluationNodeStore)] : [];
const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
@@ -177,6 +189,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
...agentNodes,
...chainNodes,
...transformNode,
...evaluationNode,
{
key: AI_OTHERS_NODE_CREATOR_VIEW,
type: 'view',
@@ -424,6 +437,18 @@ export function TriggerView() {
icon: 'fa:comments',
},
},
{
key: EVALUATION_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
group: [],
name: EVALUATION_TRIGGER_NODE_TYPE,
displayName: 'Evaluation Trigger',
description: 'Run a dataset through your workflow to test performance',
icon: 'fa:check-double',
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,

View File

@@ -217,6 +217,7 @@ export const SLACK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.slackTrigger';
export const TELEGRAM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.telegramTrigger';
export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadAdsTrigger';
export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook';
export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger';
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
@@ -746,6 +747,12 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [
export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource';
export const EVALUATION_TRIGGER = {
name: '031-evaluation-trigger',
control: 'control',
variant: 'variant',
};
export const EASY_AI_WORKFLOW_EXPERIMENT = {
name: '026_easy_ai_workflow',
control: 'control',

View File

@@ -0,0 +1,119 @@
import type { INodeProperties } from 'n8n-workflow';
import { document, sheet } from '../../Google/Sheet/GoogleSheetsTrigger.node';
export const setOutputProperties: INodeProperties[] = [
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
},
{
...document,
displayOptions: {
show: {
operation: ['setOutputs'],
},
},
},
{
...sheet,
displayOptions: {
show: {
operation: ['setOutputs'],
},
},
},
{
displayName: 'Outputs',
name: 'outputs',
placeholder: 'Add Output',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Output',
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Filter',
name: 'values',
values: [
{
displayName: 'Name',
name: 'outputName',
type: 'string',
default: '',
},
{
displayName: 'Value',
name: 'outputValue',
type: 'string',
default: '',
},
],
},
],
displayOptions: {
show: {
operation: ['setOutputs'],
},
},
},
];
export const setCheckIfEvaluatingProperties: INodeProperties[] = [
{
displayName:
'Routes to the evaluation branch if the execution started from an evaluation trigger. Otherwise routes to the normal branch.',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['checkIfEvaluating'],
},
},
},
];
export const setMetricsProperties: INodeProperties[] = [
{
displayName:
"Calculate the score(s) for the evaluation, then map them into this node. They will be displayed in the evaluations tab, not the Google Sheet. <a href='https://docs.n8n.io/advanced-ai/evaluations/metric-based-evaluations/#2-calculate-metrics' target='_blank'>View metric examples</a>",
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['setMetrics'],
},
},
},
{
displayName: 'Metrics to Return',
name: 'metrics',
type: 'assignmentCollection',
default: {
assignments: [
{
name: '',
value: '',
type: 'number',
},
],
},
typeOptions: {
assignment: {
disableType: true,
defaultType: 'number',
},
},
displayOptions: {
show: {
operation: ['setMetrics'],
},
},
},
];

View File

@@ -0,0 +1,15 @@
{
"node": "n8n-nodes-base.evaluation",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Utility"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluation/"
}
],
"generic": []
},
"alias": ["Test", "Metrics", "Evals", "Set Output", "Set Metrics"]
}

View File

@@ -0,0 +1,101 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
IExecuteFunctions,
INodeType,
INodeTypeDescription,
INodeExecutionData,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import {
setCheckIfEvaluatingProperties,
setMetricsProperties,
setOutputProperties,
} from './Description.node';
import { authentication } from '../../Google/Sheet/v2/actions/versionDescription';
import { listSearch, loadOptions } from '../methods';
import { checkIfEvaluating, setMetrics, setOutputs, setOutput } from '../utils/evaluationUtils';
export class Evaluation implements INodeType {
description: INodeTypeDescription = {
displayName: 'Evaluation',
icon: 'fa:check-double',
name: 'evaluation',
group: ['transform'],
version: 4.6,
description: 'Runs an evaluation',
eventTriggerDescription: '',
subtitle: '={{$parameter["operation"]}}',
defaults: {
name: 'Evaluation',
color: '#c3c9d5',
},
inputs: [NodeConnectionTypes.Main],
outputs: `={{(${setOutputs})($parameter)}}`,
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
operation: ['setOutputs'],
},
},
testedBy: 'googleApiCredentialTest',
},
{
name: 'googleSheetsOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
operation: ['setOutputs'],
},
},
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Set Outputs',
value: 'setOutputs',
},
{
name: 'Set Metrics',
value: 'setMetrics',
},
{
name: 'Check If Evaluating',
value: 'checkIfEvaluating',
},
],
default: 'setOutputs',
},
authentication,
...setOutputProperties,
...setMetricsProperties,
...setCheckIfEvaluatingProperties,
],
};
methods = { loadOptions, listSearch };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operation = this.getNodeParameter('operation', 0);
if (operation === 'setOutputs') {
return await setOutput.call(this);
} else if (operation === 'setMetrics') {
return await setMetrics.call(this);
} else {
// operation === 'checkIfEvaluating'
return await checkIfEvaluating.call(this);
}
}
}

View File

@@ -0,0 +1,15 @@
{
"node": "n8n-nodes-base.evaluationTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Utility"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluationTrigger/"
}
],
"generic": []
},
"alias": ["Test", "Metrics", "Evals", "Set Output", "Set Metrics"]
}

View File

@@ -0,0 +1,198 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
INodeType,
INodeTypeDescription,
IExecuteFunctions,
INodeExecutionData,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { document, sheet } from '../../Google/Sheet/GoogleSheetsTrigger.node';
import { readFilter } from '../../Google/Sheet/v2/actions/sheet/read.operation';
import { authentication } from '../../Google/Sheet/v2/actions/versionDescription';
import type { ILookupValues } from '../../Google/Sheet/v2/helpers/GoogleSheets.types';
import { listSearch, loadOptions } from '../methods';
import {
getGoogleSheet,
getResults,
getRowsLeft,
getNumberOfRowsLeftFiltered,
getSheet,
} from '../utils/evaluationTriggerUtils';
export let startingRow = 2;
export class EvaluationTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Evaluation Trigger',
icon: 'fa:check-double',
name: 'evaluationTrigger',
group: ['trigger'],
version: 4.6,
description: 'Run a test dataset through your workflow to check performance',
eventTriggerDescription: '',
defaults: {
name: 'When fetching a dataset row',
color: '#c3c9d5',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
'Pulls a test dataset from a Google Sheet. The workflow will run once for each row, in sequence. Tips for wiring this node up <a href="https://docs.n8n.io/advanced-ai/evaluations/tips-and-common-issues/#combining-multiple-triggers">here</a>.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
},
authentication,
document,
sheet,
{
displayName: 'Limit Rows',
name: 'limitRows',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'Whether to limit number of rows to process',
},
{
displayName: 'Max Rows to Process',
name: 'maxRows',
type: 'number',
default: 10,
description: 'Maximum number of rows to process',
noDataExpression: false,
displayOptions: { show: { limitRows: [true] } },
},
readFilter,
],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
testedBy: 'googleApiCredentialTest',
},
{
name: 'googleSheetsOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
};
methods = { loadOptions, listSearch };
async execute(this: IExecuteFunctions, startRow?: number): Promise<INodeExecutionData[][]> {
// We need to allow tests to reset the startingRow
if (startRow) {
startingRow = startRow;
}
const inputData = this.getInputData();
const MAX_ROWS = 1000;
const maxRows = this.getNodeParameter('limitRows', 0)
? (this.getNodeParameter('maxRows', 0) as number) + 1
: MAX_ROWS;
const rangeOptions = {
rangeDefinition: 'specifyRange',
headerRow: 1,
firstDataRow: startingRow,
};
const googleSheetInstance = getGoogleSheet.call(this);
const googleSheet = await getSheet.call(this, googleSheetInstance);
const allRows = await getResults.call(this, [], googleSheetInstance, googleSheet, rangeOptions);
// This is for test runner which requires a different return format
if (inputData[0].json.requestDataset) {
const testRunnerResult = await getResults.call(
this,
[],
googleSheetInstance,
googleSheet,
{},
);
const result = testRunnerResult.filter((row) => (row?.json?.row_number as number) <= maxRows);
return [result];
}
const hasFilter = this.getNodeParameter('filtersUI.values', 0, []) as ILookupValues[];
if (hasFilter.length > 0) {
const currentRow = allRows[0];
const currentRowNumber = currentRow.json?.row_number as number;
if (currentRow === undefined) {
startingRow = 2;
throw new NodeOperationError(this.getNode(), 'No row found');
}
const rowsLeft = await getNumberOfRowsLeftFiltered.call(
this,
googleSheetInstance,
googleSheet.title,
currentRowNumber + 1,
maxRows,
);
currentRow.json._rowsLeft = rowsLeft;
startingRow = currentRowNumber + 1;
if (rowsLeft === 0) {
startingRow = 2;
}
return [[currentRow]];
} else {
const currentRow = allRows.find((row) => (row?.json?.row_number as number) === startingRow);
const rowsLeft = await getRowsLeft.call(
this,
googleSheetInstance,
googleSheet.title,
`${googleSheet.title}!${startingRow}:${maxRows}`,
);
if (currentRow === undefined) {
startingRow = 2;
throw new NodeOperationError(this.getNode(), 'No row found');
}
currentRow.json._rowsLeft = rowsLeft;
startingRow += 1;
if (rowsLeft === 0) {
startingRow = 2;
}
return [[currentRow]];
}
}
}

View File

@@ -0,0 +1,2 @@
export * as loadOptions from './loadOptions';
export * as listSearch from './../../Google/Sheet/v2/methods/listSearch';

View File

@@ -0,0 +1,17 @@
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { getSheetHeaderRow } from '../../Google/Sheet/v2/methods/loadOptions';
export async function getSheetHeaderRowWithGeneratedColumnNames(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData = await getSheetHeaderRow.call(this);
return returnData.map((column, i) => {
if (column.value !== '') return column;
const indexBasedValue = `col_${i + 1}`;
return {
name: indexBasedValue,
value: indexBasedValue,
};
});
}

View File

@@ -0,0 +1,319 @@
import { mock } from 'jest-mock-extended';
import {
NodeOperationError,
type AssignmentCollectionValue,
type IExecuteFunctions,
type INodeTypes,
} from 'n8n-workflow';
import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet';
import { Evaluation } from '../Evaluation/Evaluation.node.ee';
describe('Test Evaluation', () => {
const sheetName = 'Sheet5';
const spreadsheetId = '1oqFpPgEPTGDw7BPkp1SfPXq3Cb3Hyr1SROtf-Ec4zvA';
const mockExecuteFunctions = mock<IExecuteFunctions>({});
beforeEach(() => {
(mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(mockExecuteFunctions.getNode as jest.Mock).mockReturnValue({ typeVersion: 4.6 });
(mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([
{ type: 'n8n-nodes-base.evaluationTrigger', name: 'Evaluation' },
]);
(mockExecuteFunctions.evaluateExpression as jest.Mock).mockReturnValue({
row_number: 23,
foo: 1,
bar: 2,
_rowsLeft: 2,
});
});
afterEach(() => jest.clearAllMocks());
describe('Test Evaluation Node for Set Output', () => {
jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => {
return { sheetId: 1, title: sheetName };
});
jest.spyOn(GoogleSheet.prototype, 'updateRows').mockImplementation(async () => {
return { sheetId: 1, title: sheetName };
});
jest.spyOn(GoogleSheet.prototype, 'batchUpdate').mockImplementation(async () => {
return { sheetId: 1, title: sheetName };
});
test('should throw error if output values is empty', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
'outputs.values': [],
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
operation: 'setOutputs',
};
return mockParams[key] ?? fallbackValue;
},
);
await expect(new Evaluation().execute.call(mockExecuteFunctions)).rejects.toThrow(
'No outputs to set',
);
expect(GoogleSheet.prototype.updateRows).not.toBeCalled();
expect(GoogleSheet.prototype.batchUpdate).not.toBeCalled();
});
test('should update rows and return input data for existing headers', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
'outputs.values': [{ outputName: 'foo', outputValue: 'clam' }],
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
operation: 'setOutputs',
};
return mockParams[key] ?? fallbackValue;
},
);
await new Evaluation().execute.call(mockExecuteFunctions);
expect(GoogleSheet.prototype.updateRows).toHaveBeenCalledWith(
sheetName,
[['foo', 'bar']],
'RAW',
1,
);
expect(GoogleSheet.prototype.batchUpdate).toHaveBeenCalledWith(
[
{
range: 'Sheet5!A23',
values: [['clam']],
},
],
'RAW',
);
});
test('should return empty when there is no parent evaluation trigger', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
'outputs.values': [{ outputName: 'bob', outputValue: 'clam' }],
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
operation: 'setOutputs',
};
return mockParams[key] ?? fallbackValue;
},
);
mockExecuteFunctions.getParentNodes.mockReturnValue([]);
const result = await new Evaluation().execute.call(mockExecuteFunctions);
expect(result).toEqual([]);
expect(GoogleSheet.prototype.updateRows).not.toBeCalled();
expect(GoogleSheet.prototype.batchUpdate).not.toBeCalled();
});
test('should update rows and return input data for new headers', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
'outputs.values': [{ outputName: 'bob', outputValue: 'clam' }],
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
operation: 'setOutputs',
};
return mockParams[key] ?? fallbackValue;
},
);
await new Evaluation().execute.call(mockExecuteFunctions);
expect(GoogleSheet.prototype.updateRows).toHaveBeenCalledWith(
sheetName,
[['foo', 'bar', 'bob']],
'RAW',
1,
);
expect(GoogleSheet.prototype.batchUpdate).toHaveBeenCalledWith(
[
{
range: 'Sheet5!C23',
values: [['clam']],
},
],
'RAW',
);
});
});
describe('Test Evaluation Node for Set Metrics', () => {
const nodeTypes = mock<INodeTypes>();
const evaluationMetricsNode = new Evaluation();
let mockExecuteFunction: IExecuteFunctions;
function getMockExecuteFunction(metrics: AssignmentCollectionValue['assignments']) {
return {
getInputData: jest.fn().mockReturnValue([{}]),
getNodeParameter: jest.fn((param: string, _: number) => {
if (param === 'metrics') {
return { assignments: metrics };
}
if (param === 'operation') {
return 'setMetrics';
}
return param;
}),
getNode: jest.fn().mockReturnValue({
typeVersion: 1,
}),
} as unknown as IExecuteFunctions;
}
beforeAll(() => {
mockExecuteFunction = getMockExecuteFunction([
{
id: '1',
name: 'Accuracy',
value: 0.95,
type: 'number',
},
{
id: '2',
name: 'Latency',
value: 100,
type: 'number',
},
]);
nodeTypes.getByName.mockReturnValue(evaluationMetricsNode);
jest.clearAllMocks();
});
describe('execute', () => {
it('should output the defined metrics', async () => {
const result = await evaluationMetricsNode.execute.call(mockExecuteFunction);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
const outputItem = result[0][0].json;
expect(outputItem).toEqual({
Accuracy: 0.95,
Latency: 100,
});
});
it('should handle no metrics defined', async () => {
mockExecuteFunction = getMockExecuteFunction([]);
const result = await evaluationMetricsNode.execute.call(mockExecuteFunction);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual({});
});
it('should convert string values to numbers', async () => {
const mockExecuteWithStringValues = getMockExecuteFunction([
{
id: '1',
name: 'Accuracy',
value: '0.95',
type: 'number',
},
{
id: '2',
name: 'Latency',
value: '100',
type: 'number',
},
]);
const result = await evaluationMetricsNode.execute.call(mockExecuteWithStringValues);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
const outputItem = result[0][0].json;
expect(outputItem).toEqual({
Accuracy: 0.95,
Latency: 100,
});
});
it('should throw error for non-numeric string values', async () => {
const mockExecuteWithInvalidValue = getMockExecuteFunction([
{
id: '1',
name: 'Accuracy',
value: 'not-a-number',
type: 'number',
},
]);
await expect(
evaluationMetricsNode.execute.call(mockExecuteWithInvalidValue),
).rejects.toThrow(NodeOperationError);
});
});
});
describe('Test Evaluation Node for Check If Evaluating', () => {
beforeEach(() => {
(mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue([{ json: {} }]);
(mockExecuteFunctions.getNode as jest.Mock).mockReturnValue({ typeVersion: 4.6 });
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
operation: 'checkIfEvaluating',
};
return mockParams[key] ?? fallbackValue;
},
);
});
afterEach(() => jest.clearAllMocks());
test('should return output in normal branch if normal execution', async () => {
(mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([]);
const result = await new Evaluation().execute.call(mockExecuteFunctions);
expect(result).toEqual([[], [{ json: {} }]]);
});
test('should return output in evaluation branch if evaluation execution', async () => {
(mockExecuteFunctions.getParentNodes as jest.Mock).mockReturnValue([
{ type: 'n8n-nodes-base.evaluationTrigger', name: 'Evaluation' },
]);
const result = await new Evaluation().execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {} }], []]);
});
});
});

View File

@@ -0,0 +1,321 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet';
import { EvaluationTrigger, startingRow } from '../EvaluationTrigger/EvaluationTrigger.node.ee';
import * as utils from '../utils/evaluationTriggerUtils';
describe('Evaluation Trigger Node', () => {
const sheetName = 'Sheet5';
const spreadsheetId = '1oqFpPgEPTGDw7BPkp1SfPXq3Cb3Hyr1SROtf-Ec4zvA';
let mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }),
});
describe('Without filters', () => {
beforeEach(() => {
jest.resetAllMocks();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }),
});
jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => {
return { sheetId: 1, title: sheetName };
});
// Mocks getResults() and getRowsLeft()
jest.spyOn(GoogleSheet.prototype, 'getData').mockImplementation(async (range: string) => {
if (range === `${sheetName}!1:1`) {
return [['Header1', 'Header2']];
} else if (range === `${sheetName}!2:1000`) {
return [
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
];
} else if (range === `${sheetName}!2:2`) {
// getRowsLeft with limit
return [];
} else if (range === sheetName) {
return [
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
];
} else {
return [];
}
});
});
test('should return a single row from google sheet', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
options: {},
'filtersUI.values': [],
combineFilters: 'AND',
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
};
return mockParams[key] ?? fallbackValue;
},
);
const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: {
row_number: 2,
Header1: 'Value1',
Header2: 'Value2',
_rowsLeft: 2,
},
pairedItem: {
item: 0,
},
},
],
]);
expect(startingRow).toBe(3);
});
test('should return a single row from google sheet with limit', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
options: {},
'filtersUI.values': [],
combineFilters: 'AND',
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
limitRows: true,
maxRows: 1,
};
return mockParams[key] ?? fallbackValue;
},
);
const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions, 2);
expect(result).toEqual([
[
{
json: {
row_number: 2,
Header1: 'Value1',
Header2: 'Value2',
_rowsLeft: 0,
},
pairedItem: {
item: 0,
},
},
],
]);
expect(startingRow).toBe(2);
});
test('should return the sheet with limits applied when test runner is enabled', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { requestDataset: true } }]);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
options: {},
'filtersUI.values': [],
combineFilters: 'AND',
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
limitRows: true,
maxRows: 2,
};
return mockParams[key] ?? fallbackValue;
},
);
const result = await new EvaluationTrigger().execute.call(mockExecuteFunctions, 2);
expect(result).toEqual([
[
{
json: {
row_number: 2,
Header1: 'Value1',
Header2: 'Value2',
},
pairedItem: {
item: 0,
},
},
{
json: {
row_number: 3,
Header1: 'Value3',
Header2: 'Value4',
},
pairedItem: {
item: 0,
},
},
],
]);
expect(startingRow).toBe(2);
});
});
describe('With filters', () => {
beforeEach(() => {
jest.resetAllMocks();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNode: jest.fn().mockReturnValue({ typeVersion: 4.6 }),
});
jest.spyOn(GoogleSheet.prototype, 'spreadsheetGetSheet').mockImplementation(async () => {
return { sheetId: 1, title: sheetName };
});
});
test('should return all relevant rows from google sheet using filter and test runner enabled', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { requestDataset: true } }]);
jest
.spyOn(GoogleSheet.prototype, 'getData')
.mockResolvedValueOnce([
// operationResult
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
['Value1', 'Value4'],
])
.mockResolvedValueOnce([
// rowsLeft
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
['Value1', 'Value4'],
]);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }],
options: {},
combineFilters: 'AND',
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
};
return mockParams[key] ?? fallbackValue;
},
);
jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0);
const evaluationTrigger = new EvaluationTrigger();
const result = await evaluationTrigger.execute.call(mockExecuteFunctions, 1);
expect(result).toEqual([
[
{
json: { row_number: 2, Header1: 'Value1', Header2: 'Value2' },
pairedItem: {
item: 0,
},
},
{
json: { row_number: 4, Header1: 'Value1', Header2: 'Value4' },
pairedItem: {
item: 0,
},
},
],
]);
});
test('should return a single row from google sheet using filter', async () => {
jest
.spyOn(GoogleSheet.prototype, 'getData')
.mockResolvedValueOnce([
// operationResult
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
])
.mockResolvedValueOnce([
// rowsLeft
['Header1', 'Header2'],
['Value1', 'Value2'],
['Value3', 'Value4'],
]);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(key: string, _: number, fallbackValue?: string | number | boolean | object) => {
const mockParams: { [key: string]: unknown } = {
limitRows: true,
maxRows: 2,
'filtersUI.values': [{ lookupColumn: 'Header1', lookupValue: 'Value1' }],
options: {},
combineFilters: 'AND',
documentId: {
mode: 'id',
value: spreadsheetId,
},
sheetName,
sheetMode: 'id',
};
return mockParams[key] ?? fallbackValue;
},
);
jest.spyOn(utils, 'getRowsLeft').mockResolvedValue(0);
const evaluationTrigger = new EvaluationTrigger();
const result = await evaluationTrigger.execute.call(mockExecuteFunctions, 1);
expect(result).toEqual([
[
{
json: {
row_number: 2,
Header1: 'Value1',
Header2: 'Value2',
_rowsLeft: 0,
},
pairedItem: {
item: 0,
},
},
],
]);
});
});
});

View File

@@ -0,0 +1,101 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { readSheet } from '../../Google/Sheet/v2/actions/utils/readOperation';
import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet';
import { getFilteredResults } from '../utils/evaluationTriggerUtils';
jest.mock('../../Google/Sheet/v2/actions/utils/readOperation', () => ({
readSheet: jest.fn(),
}));
describe('getFilteredResults', () => {
let mockThis: IExecuteFunctions;
let mockGoogleSheet: GoogleSheet;
beforeEach(() => {
// Mock the `this` context
mockThis = {
getNode: jest.fn().mockReturnValue({ typeVersion: 1 }),
} as unknown as IExecuteFunctions;
// Mock the GoogleSheet instance
mockGoogleSheet = new GoogleSheet('mockSpreadsheetId', mockThis);
// Reset mocks before each test
jest.clearAllMocks();
});
it('should return filtered results based on endingRow', async () => {
// Arrange
const mockOperationResult: INodeExecutionData[] = [];
const mockResult = { title: 'Sheet1', sheetId: 1 };
const startingRow = 1;
const endingRow = 3;
(readSheet as jest.Mock).mockResolvedValue([
{ json: { row_number: 1, data: 'Row 1' } },
{ json: { row_number: 2, data: 'Row 2' } },
{ json: { row_number: 3, data: 'Row 3' } },
{ json: { row_number: 4, data: 'Row 4' } },
]);
// Act
const result = await getFilteredResults.call(
mockThis,
mockOperationResult,
mockGoogleSheet,
mockResult,
startingRow,
endingRow,
);
// Assert
expect(readSheet).toHaveBeenCalledWith(
mockGoogleSheet,
'Sheet1',
0,
mockOperationResult,
1,
[],
undefined,
{
rangeDefinition: 'specifyRange',
headerRow: 1,
firstDataRow: startingRow,
},
);
expect(result).toEqual([
{ json: { row_number: 1, data: 'Row 1' } },
{ json: { row_number: 2, data: 'Row 2' } },
{ json: { row_number: 3, data: 'Row 3' } },
]);
});
it('should return an empty array if no rows match the filter', async () => {
// Arrange
const mockOperationResult: INodeExecutionData[] = [];
const mockResult = { title: 'Sheet1', sheetId: 1 };
const startingRow = 1;
const endingRow = 0;
(readSheet as jest.Mock).mockResolvedValue([
{ json: { row_number: 1, data: 'Row 1' } },
{ json: { row_number: 2, data: 'Row 2' } },
]);
// Act
const result = await getFilteredResults.call(
mockThis,
mockOperationResult,
mockGoogleSheet,
mockResult,
startingRow,
endingRow,
);
// Assert
expect(readSheet).toHaveBeenCalled();
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,63 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
import { type ILoadOptionsFunctions } from 'n8n-workflow';
import { getSheetHeaderRow } from '../../Google/Sheet/v2/methods/loadOptions';
import { getSheetHeaderRowWithGeneratedColumnNames } from '../methods/loadOptions';
jest.mock('../../Google/Sheet/v2/methods/loadOptions', () => ({
getSheetHeaderRow: jest.fn(),
}));
describe('getSheetHeaderRowWithGeneratedColumnNames', () => {
let mockThis: ILoadOptionsFunctions;
beforeEach(() => {
mockThis = {
getNodeParameter: jest.fn(),
getCredentials: jest.fn(),
} as unknown as ILoadOptionsFunctions;
jest.clearAllMocks();
});
it('should return column names as-is if they are not empty', async () => {
(getSheetHeaderRow as jest.Mock).mockResolvedValue([
{ name: 'Column1', value: 'Column1' },
{ name: 'Column2', value: 'Column2' },
]);
const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis);
expect(getSheetHeaderRow).toHaveBeenCalled();
expect(result).toEqual([
{ name: 'Column1', value: 'Column1' },
{ name: 'Column2', value: 'Column2' },
]);
});
it('should generate column names for empty values', async () => {
(getSheetHeaderRow as jest.Mock).mockResolvedValue([
{ name: '', value: '' },
{ name: 'Column2', value: 'Column2' },
{ name: '', value: '' },
]);
const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis);
expect(getSheetHeaderRow).toHaveBeenCalled();
expect(result).toEqual([
{ name: 'col_1', value: 'col_1' },
{ name: 'Column2', value: 'Column2' },
{ name: 'col_3', value: 'col_3' },
]);
});
it('should handle an empty header row gracefully', async () => {
(getSheetHeaderRow as jest.Mock).mockResolvedValue([]);
const result = await getSheetHeaderRowWithGeneratedColumnNames.call(mockThis);
expect(getSheetHeaderRow).toHaveBeenCalled();
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,131 @@
import type { IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
import { readSheet } from '../../Google/Sheet/v2/actions/utils/readOperation';
import { GoogleSheet } from '../../Google/Sheet/v2/helpers/GoogleSheet';
import type { ResourceLocator } from '../../Google/Sheet/v2/helpers/GoogleSheets.types';
import { getSpreadsheetId } from '../../Google/Sheet/v2/helpers/GoogleSheets.utils';
export async function getSheet(
this: IExecuteFunctions,
googleSheet: GoogleSheet,
): Promise<{
title: string;
sheetId: number;
}> {
const sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, {
extractValue: true,
}) as string;
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
mode: ResourceLocator;
};
return await googleSheet.spreadsheetGetSheet(this.getNode(), sheetMode, sheetWithinDocument);
}
export function getGoogleSheet(this: IExecuteFunctions) {
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
const googleSheet = new GoogleSheet(spreadsheetId, this);
return googleSheet;
}
export async function getFilteredResults(
this: IExecuteFunctions,
operationResult: INodeExecutionData[],
googleSheet: GoogleSheet,
result: { title: string; sheetId: number },
startingRow: number,
endingRow: number,
): Promise<INodeExecutionData[]> {
const sheetName = result.title;
operationResult = await readSheet.call(
this,
googleSheet,
sheetName,
0,
operationResult,
this.getNode().typeVersion,
[],
undefined,
{
rangeDefinition: 'specifyRange',
headerRow: 1,
firstDataRow: startingRow,
},
);
return operationResult.filter((row) => (row?.json?.row_number as number) <= endingRow);
}
export async function getNumberOfRowsLeftFiltered(
this: IExecuteFunctions,
googleSheet: GoogleSheet,
sheetName: string,
startingRow: number,
endingRow: number,
) {
const remainderSheet: INodeExecutionData[] = await readSheet.call(
this,
googleSheet,
sheetName,
0,
[],
this.getNode().typeVersion,
[],
undefined,
{
rangeDefinition: 'specifyRange',
headerRow: 1,
firstDataRow: startingRow,
},
);
return remainderSheet.filter((row) => (row?.json?.row_number as number) <= endingRow).length;
}
export async function getResults(
this: IExecuteFunctions,
operationResult: INodeExecutionData[],
googleSheet: GoogleSheet,
result: { title: string; sheetId: number },
rangeOptions: IDataObject,
): Promise<INodeExecutionData[]> {
const sheetName = result.title;
operationResult = await readSheet.call(
this,
googleSheet,
sheetName,
0,
operationResult,
this.getNode().typeVersion,
[],
undefined,
rangeOptions,
);
return operationResult;
}
export async function getRowsLeft(
this: IExecuteFunctions,
googleSheet: GoogleSheet,
sheetName: string,
rangeString: string,
) {
const remainderSheet: INodeExecutionData[] = await readSheet.call(
this,
googleSheet,
sheetName,
0,
[],
this.getNode().typeVersion,
[],
rangeString,
);
return remainderSheet.length;
}

View File

@@ -0,0 +1,182 @@
import { NodeOperationError, UserError } from 'n8n-workflow';
import type {
FieldType,
INodeParameters,
AssignmentCollectionValue,
IDataObject,
IExecuteFunctions,
INodeExecutionData,
} from 'n8n-workflow';
import { getGoogleSheet, getSheet } from './evaluationTriggerUtils';
import { composeReturnItem, validateEntry } from '../../Set/v2/helpers/utils';
export async function setOutput(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const evaluationNode = this.getNode();
const parentNodes = this.getParentNodes(evaluationNode.name);
const evalTrigger = parentNodes.find((node) => node.type === 'n8n-nodes-base.evaluationTrigger');
const evalTriggerOutput = evalTrigger
? this.evaluateExpression(`{{ $('${evalTrigger?.name}').isExecuted }}`, 0)
: undefined;
if (!evalTrigger || !evalTriggerOutput) {
this.addExecutionHints({
message: "No outputs were set since the execution didn't start from an evaluation trigger",
location: 'outputPane',
});
return [];
}
const outputFields = this.getNodeParameter('outputs.values', 0, []) as Array<{
outputName: string;
outputValue: string;
}>;
if (outputFields.length === 0) {
throw new UserError('No outputs to set', {
description: 'Add outputs to write back to the Google Sheet using the Add Output button',
});
}
const googleSheetInstance = getGoogleSheet.call(this);
const googleSheet = await getSheet.call(this, googleSheetInstance);
const evaluationTrigger = this.evaluateExpression(
`{{ $('${evalTrigger.name}').first().json }}`,
0,
) as IDataObject;
const rowNumber =
evaluationTrigger.row_number === 'row_number' ? 1 : evaluationTrigger.row_number;
const columnNames = Object.keys(evaluationTrigger).filter(
(key) => key !== 'row_number' && key !== '_rowsLeft',
);
outputFields.forEach(({ outputName }) => {
if (!columnNames.includes(outputName)) {
columnNames.push(outputName);
}
});
await googleSheetInstance.updateRows(
googleSheet.title,
[columnNames],
'RAW', // default value for Value Input Mode
1, // header row
);
const outputs = outputFields.reduce((acc, { outputName, outputValue }) => {
acc[outputName] = outputValue;
return acc;
}, {} as IDataObject);
const preparedData = googleSheetInstance.prepareDataForUpdatingByRowNumber(
[
{
row_number: rowNumber,
...outputs,
},
],
`${googleSheet.title}!A:Z`,
[columnNames],
);
await googleSheetInstance.batchUpdate(
preparedData.updateData,
'RAW', // default value for Value Input Mode
);
return [this.getInputData()];
}
export async function setMetrics(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const metrics: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
const dataToSave = this.getNodeParameter('metrics', i, {}) as AssignmentCollectionValue;
const newItem: INodeExecutionData = {
json: {},
pairedItem: { item: i },
};
const newData = Object.fromEntries(
(dataToSave?.assignments ?? []).map((assignment) => {
const assignmentValue =
typeof assignment.value === 'number' ? assignment.value : Number(assignment.value);
if (!assignment.name || isNaN(assignmentValue)) {
throw new NodeOperationError(this.getNode(), 'Metric name missing', {
description: 'Make sure each metric you define has a name',
});
}
if (isNaN(assignmentValue)) {
throw new NodeOperationError(
this.getNode(),
`Value for '${assignment.name}' isn't a number`,
{
description: `Its currently '${assignment.value}'. Metrics must be numeric.`,
},
);
}
const { name, value } = validateEntry(
assignment.name,
assignment.type as FieldType,
assignmentValue,
this.getNode(),
i,
false,
1,
);
return [name, value];
}),
);
const returnItem = composeReturnItem.call(
this,
i,
newItem,
newData,
{ dotNotation: false, include: 'none' },
1,
);
metrics.push(returnItem);
}
return [metrics];
}
export async function checkIfEvaluating(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const evaluationExecutionResult: INodeExecutionData[] = [];
const normalExecutionResult: INodeExecutionData[] = [];
const evaluationNode = this.getNode();
const parentNodes = this.getParentNodes(evaluationNode.name);
const evalTrigger = parentNodes.find((node) => node.type === 'n8n-nodes-base.evaluationTrigger');
const evalTriggerOutput = evalTrigger
? this.evaluateExpression(`{{ $('${evalTrigger?.name}').isExecuted }}`, 0)
: undefined;
if (evalTriggerOutput) {
return [this.getInputData(), normalExecutionResult];
} else {
return [evaluationExecutionResult, this.getInputData()];
}
}
export function setOutputs(parameters: INodeParameters) {
if (parameters.operation === 'checkIfEvaluating') {
return [
{ type: 'main', displayName: 'Evaluation' },
{ type: 'main', displayName: 'Normal' },
];
}
return [{ type: 'main' }];
}

View File

@@ -507,6 +507,8 @@
"dist/nodes/Emelia/EmeliaTrigger.node.js",
"dist/nodes/ERPNext/ERPNext.node.js",
"dist/nodes/ErrorTrigger/ErrorTrigger.node.js",
"dist/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.js",
"dist/nodes/Evaluation/Evaluation/Evaluation.node.ee.js",
"dist/nodes/Eventbrite/EventbriteTrigger.node.js",
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",

View File

@@ -27,6 +27,7 @@ export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const START_NODE_TYPE = 'n8n-nodes-base.start';
export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow';
@@ -46,6 +47,7 @@ export const STARTING_NODE_TYPES = [
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
ERROR_TRIGGER_NODE_TYPE,
START_NODE_TYPE,
EVALUATION_TRIGGER_NODE_TYPE,
];
export const SCRIPTING_NODE_TYPES = [

View File

@@ -3,8 +3,8 @@ import callsites from 'callsites';
import type { ErrorTags, ErrorLevel, ReportingOptions } from '../error.types';
export type BaseErrorOptions = { description?: undefined | null } & ErrorOptions & ReportingOptions;
export type BaseErrorOptions = { description?: string | undefined | null } & ErrorOptions &
ReportingOptions;
/**
* Base class for all errors
*/