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')
|
cy.getByTestId('node-creator-item-name')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.filter(':contains("Trigger")')
|
.filter(':contains("Trigger manually")')
|
||||||
.click();
|
.click();
|
||||||
executionsTab.actions.switchToExecutionsTab();
|
executionsTab.actions.switchToExecutionsTab();
|
||||||
executionsTab.getters.executionsSidebar().should('be.visible');
|
executionsTab.getters.executionsSidebar().should('be.visible');
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('NodesListPanel', () => {
|
|||||||
await fireEvent.click(container.querySelector('.backButton')!);
|
await fireEvent.click(container.querySelector('.backButton')!);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
|
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render regular nodes', async () => {
|
it('should render regular nodes', async () => {
|
||||||
|
|||||||
@@ -132,3 +132,63 @@ exports[`viewsData > AIView > should return ai view without ai transform node if
|
|||||||
"value": "AI",
|
"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_CATEGORY_TOOLS,
|
||||||
AI_SUBCATEGORY,
|
AI_SUBCATEGORY,
|
||||||
CUSTOM_API_CALL_KEY,
|
CUSTOM_API_CALL_KEY,
|
||||||
|
EVALUATION_TRIGGER,
|
||||||
HTTP_REQUEST_NODE_TYPE,
|
HTTP_REQUEST_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { memoize, startCase } from 'lodash-es';
|
import { memoize, startCase } from 'lodash-es';
|
||||||
@@ -19,6 +20,7 @@ import { i18n } from '@/plugins/i18n';
|
|||||||
|
|
||||||
import { getCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
|
import { getCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
|
||||||
import { formatTriggerActionName } from '../utils';
|
import { formatTriggerActionName } from '../utils';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
|
||||||
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
|
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
|
||||||
|
|
||||||
@@ -330,7 +332,23 @@ export function useActionsGenerator() {
|
|||||||
nodeTypes: INodeTypeDescription[],
|
nodeTypes: INodeTypeDescription[],
|
||||||
httpOnlyCredentials: ICredentialType[],
|
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 actions: ActionsRecord<typeof mergedNodes> = {};
|
||||||
const mergedNodes: SimplifiedNodeType[] = [];
|
const mergedNodes: SimplifiedNodeType[] = [];
|
||||||
visibleNodeTypes
|
visibleNodeTypes
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NodeConnectionTypes, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow';
|
import { NodeConnectionTypes, type INodeProperties, type INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { useActionsGenerator } from './composables/useActionsGeneration';
|
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', () => {
|
describe('useActionsGenerator', () => {
|
||||||
const { generateMergedNodesAndActions } = useActionsGenerator();
|
const { generateMergedNodesAndActions } = useActionsGenerator();
|
||||||
@@ -19,6 +24,17 @@ describe('useActionsGenerator', () => {
|
|||||||
properties: [],
|
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', () => {
|
describe('App actions for resource category', () => {
|
||||||
const resourcePropertyWithUser: INodeProperties = {
|
const resourcePropertyWithUser: INodeProperties = {
|
||||||
displayName: 'Resource',
|
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 { AIView } from './viewsData';
|
||||||
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
|
||||||
|
let posthogStore: ReturnType<typeof usePostHog>;
|
||||||
|
|
||||||
const getNodeType = vi.fn();
|
const getNodeType = vi.fn();
|
||||||
|
|
||||||
@@ -51,6 +54,9 @@ describe('viewsData', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setActivePinia(createTestingPinia());
|
setActivePinia(createTestingPinia());
|
||||||
|
|
||||||
|
posthogStore = usePostHog();
|
||||||
|
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
|
|
||||||
vi.spyOn(templatesStore, 'websiteTemplateRepositoryParameters', 'get').mockImplementation(
|
vi.spyOn(templatesStore, 'websiteTemplateRepositoryParameters', 'get').mockImplementation(
|
||||||
@@ -86,5 +92,14 @@ describe('viewsData', () => {
|
|||||||
|
|
||||||
expect(AIView([])).toMatchSnapshot();
|
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_CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
HUMAN_IN_THE_LOOP_CATEGORY,
|
HUMAN_IN_THE_LOOP_CATEGORY,
|
||||||
|
EVALUATION_TRIGGER,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import type { SimplifiedNodeType } from '@/Interface';
|
import type { SimplifiedNodeType } from '@/Interface';
|
||||||
import type { INodeTypeDescription, Themed } from 'n8n-workflow';
|
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 type { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import { camelCase } from 'lodash-es';
|
import { camelCase } from 'lodash-es';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
|
||||||
export interface NodeViewItemSection {
|
export interface NodeViewItemSection {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -141,6 +143,16 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const templatesStore = useTemplatesStore();
|
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 chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
|
||||||
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
|
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
|
||||||
@@ -177,6 +189,7 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
|
|||||||
...agentNodes,
|
...agentNodes,
|
||||||
...chainNodes,
|
...chainNodes,
|
||||||
...transformNode,
|
...transformNode,
|
||||||
|
...evaluationNode,
|
||||||
{
|
{
|
||||||
key: AI_OTHERS_NODE_CREATOR_VIEW,
|
key: AI_OTHERS_NODE_CREATOR_VIEW,
|
||||||
type: 'view',
|
type: 'view',
|
||||||
@@ -424,6 +437,18 @@ export function TriggerView() {
|
|||||||
icon: 'fa:comments',
|
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',
|
type: 'subcategory',
|
||||||
key: OTHER_TRIGGER_NODES_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 TELEGRAM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.telegramTrigger';
|
||||||
export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadAdsTrigger';
|
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 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_NODE_PREFIX = 'n8n-creds-base';
|
||||||
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
|
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 MAIN_AUTH_FIELD_NAME = 'authentication';
|
||||||
export const NODE_RESOURCE_FIELD_NAME = 'resource';
|
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 = {
|
export const EASY_AI_WORKFLOW_EXPERIMENT = {
|
||||||
name: '026_easy_ai_workflow',
|
name: '026_easy_ai_workflow',
|
||||||
control: 'control',
|
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/Emelia/EmeliaTrigger.node.js",
|
||||||
"dist/nodes/ERPNext/ERPNext.node.js",
|
"dist/nodes/ERPNext/ERPNext.node.js",
|
||||||
"dist/nodes/ErrorTrigger/ErrorTrigger.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/Eventbrite/EventbriteTrigger.node.js",
|
||||||
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
|
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
|
||||||
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.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 HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
|
||||||
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
|
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
|
||||||
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
|
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 ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
|
||||||
export const START_NODE_TYPE = 'n8n-nodes-base.start';
|
export const START_NODE_TYPE = 'n8n-nodes-base.start';
|
||||||
export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow';
|
export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow';
|
||||||
@@ -46,6 +47,7 @@ export const STARTING_NODE_TYPES = [
|
|||||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||||
ERROR_TRIGGER_NODE_TYPE,
|
ERROR_TRIGGER_NODE_TYPE,
|
||||||
START_NODE_TYPE,
|
START_NODE_TYPE,
|
||||||
|
EVALUATION_TRIGGER_NODE_TYPE,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SCRIPTING_NODE_TYPES = [
|
export const SCRIPTING_NODE_TYPES = [
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import callsites from 'callsites';
|
|||||||
|
|
||||||
import type { ErrorTags, ErrorLevel, ReportingOptions } from '../error.types';
|
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
|
* Base class for all errors
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user