mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(Evaluation Metrics Node): Add Evaluation Metrics node (no-changelog) (#14050)
This commit is contained in:
@@ -17,6 +17,7 @@ interface Props {
|
|||||||
modelValue: AssignmentValue;
|
modelValue: AssignmentValue;
|
||||||
issues: string[];
|
issues: string[];
|
||||||
hideType?: boolean;
|
hideType?: boolean;
|
||||||
|
disableType?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ const onBlur = (): void => {
|
|||||||
<TypeSelect
|
<TypeSelect
|
||||||
:class="$style.select"
|
:class="$style.select"
|
||||||
:model-value="assignment.type ?? 'string'"
|
:model-value="assignment.type ?? 'string'"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="disableType || isReadOnly"
|
||||||
@update:model-value="onAssignmentTypeChange"
|
@update:model-value="onAssignmentTypeChange"
|
||||||
>
|
>
|
||||||
</TypeSelect>
|
</TypeSelect>
|
||||||
|
|||||||
@@ -151,4 +151,69 @@ describe('AssignmentCollection.vue', () => {
|
|||||||
expect(getAssignmentType(assignments[3])).toEqual('Object');
|
expect(getAssignmentType(assignments[3])).toEqual('Object');
|
||||||
expect(getAssignmentType(assignments[4])).toEqual('Array');
|
expect(getAssignmentType(assignments[4])).toEqual('Array');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('defaultType prop', () => {
|
||||||
|
it('should use string as default type when no defaultType is specified', async () => {
|
||||||
|
const { getByTestId, findAllByTestId } = renderComponent();
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||||
|
|
||||||
|
const assignments = await findAllByTestId('assignment');
|
||||||
|
expect(assignments.length).toBe(1);
|
||||||
|
expect(getAssignmentType(assignments[0])).toEqual('String');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use specified defaultType when adding a new assignment manually', async () => {
|
||||||
|
const { getByTestId, findAllByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
defaultType: 'number',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||||
|
|
||||||
|
const assignments = await findAllByTestId('assignment');
|
||||||
|
expect(assignments.length).toBe(1);
|
||||||
|
expect(getAssignmentType(assignments[0])).toEqual('Number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use defaultType for drag and drop when disableType is true', async () => {
|
||||||
|
const { getByTestId, findAllByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
defaultType: 'number',
|
||||||
|
disableType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropArea = getByTestId('drop-area');
|
||||||
|
|
||||||
|
// Even though we're dropping a string value, it should use number type because of defaultType
|
||||||
|
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
|
||||||
|
|
||||||
|
const assignments = await findAllByTestId('assignment');
|
||||||
|
expect(assignments.length).toBe(1);
|
||||||
|
expect(getAssignmentType(assignments[0])).toEqual('Number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect defaultType for all assignments when provided', async () => {
|
||||||
|
const { getByTestId, findAllByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
defaultType: 'boolean',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropArea = getByTestId('drop-area');
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||||
|
|
||||||
|
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
|
||||||
|
await dropAssignment({ key: 'numberKey', value: 25, dropArea });
|
||||||
|
|
||||||
|
const assignments = await findAllByTestId('assignment');
|
||||||
|
expect(assignments.length).toBe(3);
|
||||||
|
expect(getAssignmentType(assignments[0])).toEqual('Boolean');
|
||||||
|
expect(getAssignmentType(assignments[1])).toEqual('Boolean');
|
||||||
|
expect(getAssignmentType(assignments[2])).toEqual('Boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||||||
import type {
|
import type {
|
||||||
AssignmentCollectionValue,
|
AssignmentCollectionValue,
|
||||||
AssignmentValue,
|
AssignmentValue,
|
||||||
|
FieldTypeMap,
|
||||||
INode,
|
INode,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
@@ -20,11 +21,17 @@ interface Props {
|
|||||||
parameter: INodeProperties;
|
parameter: INodeProperties;
|
||||||
value: AssignmentCollectionValue;
|
value: AssignmentCollectionValue;
|
||||||
path: string;
|
path: string;
|
||||||
|
defaultType?: keyof FieldTypeMap;
|
||||||
|
disableType?: boolean;
|
||||||
node: INode | null;
|
node: INode | null;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isReadOnly: false,
|
||||||
|
defaultType: undefined,
|
||||||
|
disableType: false,
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
|
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
|
||||||
@@ -82,7 +89,7 @@ function addAssignment(): void {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: '',
|
name: '',
|
||||||
value: '',
|
value: '',
|
||||||
type: 'string',
|
type: props.defaultType ?? 'string',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +98,7 @@ function dropAssignment(expression: string): void {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: propertyNameFromExpression(expression),
|
name: propertyNameFromExpression(expression),
|
||||||
value: `=${expression}`,
|
value: `=${expression}`,
|
||||||
type: typeFromExpression(expression),
|
type: props.defaultType ?? typeFromExpression(expression),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +164,7 @@ function optionSelected(action: string) {
|
|||||||
:issues="getIssues(index)"
|
:issues="getIssues(index)"
|
||||||
:class="$style.assignment"
|
:class="$style.assignment"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
|
:disable-type="disableType"
|
||||||
@update:model-value="(value) => onAssignmentUpdate(index, value)"
|
@update:model-value="(value) => onAssignmentUpdate(index, value)"
|
||||||
@remove="() => onAssignmentRemove(index)"
|
@remove="() => onAssignmentRemove(index)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -665,6 +665,8 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
|
|||||||
:path="getPath(parameter.name)"
|
:path="getPath(parameter.name)"
|
||||||
:node="node"
|
:node="node"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
|
:default-type="parameter.typeOptions?.assignment?.defaultType"
|
||||||
|
:disable-type="parameter.typeOptions?.assignment?.disableType"
|
||||||
@value-changed="valueChanged"
|
@value-changed="valueChanged"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">
|
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
faChartBar,
|
faChartBar,
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
|
faCheckDouble,
|
||||||
faCheckSquare,
|
faCheckSquare,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
faChevronUp,
|
faChevronUp,
|
||||||
@@ -225,6 +226,7 @@ export const FontAwesomePlugin: Plugin = {
|
|||||||
addIcon(faChartBar);
|
addIcon(faChartBar);
|
||||||
addIcon(faCheck);
|
addIcon(faCheck);
|
||||||
addIcon(faCheckCircle);
|
addIcon(faCheckCircle);
|
||||||
|
addIcon(faCheckDouble);
|
||||||
addIcon(faCheckSquare);
|
addIcon(faCheckSquare);
|
||||||
addIcon(faChevronLeft);
|
addIcon(faChevronLeft);
|
||||||
addIcon(faChevronRight);
|
addIcon(faChevronRight);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"node": "n8n-nodes-base.evaluationMetrics",
|
||||||
|
"nodeVersion": "1.0",
|
||||||
|
"codexVersion": "1.0",
|
||||||
|
"categories": ["Evaluation", "Core Nodes"],
|
||||||
|
"resources": {
|
||||||
|
"primaryDocumentation": [
|
||||||
|
{
|
||||||
|
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluationmetrics/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"alias": ["Metric"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { INodeTypes, IExecuteFunctions, AssignmentCollectionValue } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { EvaluationMetrics } from './EvaluationMetrics.node';
|
||||||
|
|
||||||
|
describe('EvaluationMetrics Node', () => {
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const evaluationMetricsNode = new EvaluationMetrics();
|
||||||
|
|
||||||
|
let mockExecuteFunction: IExecuteFunctions;
|
||||||
|
|
||||||
|
function getMockExecuteFunction(metrics: AssignmentCollectionValue['assignments']) {
|
||||||
|
return {
|
||||||
|
getInputData: jest.fn().mockReturnValue([{}]),
|
||||||
|
|
||||||
|
getNodeParameter: jest.fn().mockReturnValueOnce({
|
||||||
|
assignments: metrics,
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type {
|
||||||
|
AssignmentCollectionValue,
|
||||||
|
FieldType,
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeDescription,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { composeReturnItem, validateEntry } from '../Set/v2/helpers/utils';
|
||||||
|
|
||||||
|
export class EvaluationMetrics implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Evaluation Metrics',
|
||||||
|
name: 'evaluationMetrics',
|
||||||
|
icon: 'fa:check-double',
|
||||||
|
group: ['input'],
|
||||||
|
iconColor: 'light-green',
|
||||||
|
version: 1,
|
||||||
|
description: 'Define the metrics returned for workflow evaluation',
|
||||||
|
defaults: {
|
||||||
|
name: 'Evaluation Metrics',
|
||||||
|
color: '#29A568',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionTypes.Main],
|
||||||
|
outputs: [NodeConnectionTypes.Main],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
"Define the evaluation metrics returned in your report. Only numeric values are supported. <a href='https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluationmetric/' target='_blank'>More Info</a>",
|
||||||
|
name: 'notice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Metrics to Return',
|
||||||
|
name: 'metrics',
|
||||||
|
type: 'assignmentCollection',
|
||||||
|
default: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
value: '',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
typeOptions: {
|
||||||
|
assignment: {
|
||||||
|
disableType: true,
|
||||||
|
defaultType: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(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 (isNaN(assignmentValue)) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
`Invalid numeric value: "${assignment.value}". Please provide a valid number.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -504,6 +504,7 @@
|
|||||||
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
|
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
|
||||||
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
|
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
|
||||||
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
||||||
|
"dist/nodes/EvaluationMetrics/EvaluationMetrics.node.js",
|
||||||
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
||||||
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
||||||
"dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js",
|
"dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js",
|
||||||
|
|||||||
@@ -1352,6 +1352,8 @@ export type FilterTypeOptions = {
|
|||||||
|
|
||||||
export type AssignmentTypeOptions = Partial<{
|
export type AssignmentTypeOptions = Partial<{
|
||||||
hideType?: boolean; // visible by default
|
hideType?: boolean; // visible by default
|
||||||
|
defaultType?: FieldType | 'string';
|
||||||
|
disableType?: boolean; // visible by default
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type DisplayCondition =
|
export type DisplayCondition =
|
||||||
|
|||||||
Reference in New Issue
Block a user