mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(n8n Evaluation Trigger Node): Add Evaluation Trigger and Evaluation Node (#15194)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/nodes-base/nodes/Evaluation/methods/index.ts
Normal file
2
packages/nodes-base/nodes/Evaluation/methods/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as loadOptions from './loadOptions';
|
||||
export * as listSearch from './../../Google/Sheet/v2/methods/listSearch';
|
||||
17
packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts
Normal file
17
packages/nodes-base/nodes/Evaluation/methods/loadOptions.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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: {} }], []]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
182
packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts
Normal file
182
packages/nodes-base/nodes/Evaluation/utils/evaluationUtils.ts
Normal 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: `It’s 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' }];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user