feat(Evaluation Metrics Node): Add Evaluation Metrics node (no-changelog) (#14050)

This commit is contained in:
oleg
2025-03-25 14:45:20 +01:00
committed by GitHub
parent 8aad7dbaf6
commit 22e6569f7e
10 changed files with 319 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ interface Props {
modelValue: AssignmentValue;
issues: string[];
hideType?: boolean;
disableType?: boolean;
isReadOnly?: boolean;
index?: number;
}
@@ -163,7 +164,7 @@ const onBlur = (): void => {
<TypeSelect
:class="$style.select"
:model-value="assignment.type ?? 'string'"
:is-read-only="isReadOnly"
:is-read-only="disableType || isReadOnly"
@update:model-value="onAssignmentTypeChange"
>
</TypeSelect>

View File

@@ -151,4 +151,69 @@ describe('AssignmentCollection.vue', () => {
expect(getAssignmentType(assignments[3])).toEqual('Object');
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');
});
});
});

View File

@@ -5,6 +5,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import type {
AssignmentCollectionValue,
AssignmentValue,
FieldTypeMap,
INode,
INodeProperties,
} from 'n8n-workflow';
@@ -20,11 +21,17 @@ interface Props {
parameter: INodeProperties;
value: AssignmentCollectionValue;
path: string;
defaultType?: keyof FieldTypeMap;
disableType?: boolean;
node: INode | null;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
defaultType: undefined,
disableType: false,
});
const emit = defineEmits<{
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
@@ -82,7 +89,7 @@ function addAssignment(): void {
id: crypto.randomUUID(),
name: '',
value: '',
type: 'string',
type: props.defaultType ?? 'string',
});
}
@@ -91,7 +98,7 @@ function dropAssignment(expression: string): void {
id: crypto.randomUUID(),
name: propertyNameFromExpression(expression),
value: `=${expression}`,
type: typeFromExpression(expression),
type: props.defaultType ?? typeFromExpression(expression),
});
}
@@ -157,6 +164,7 @@ function optionSelected(action: string) {
:issues="getIssues(index)"
:class="$style.assignment"
:is-read-only="isReadOnly"
:disable-type="disableType"
@update:model-value="(value) => onAssignmentUpdate(index, value)"
@remove="() => onAssignmentRemove(index)"
>

View File

@@ -665,6 +665,8 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
:path="getPath(parameter.name)"
:node="node"
:is-read-only="isReadOnly"
:default-type="parameter.typeOptions?.assignment?.defaultType"
:disable-type="parameter.typeOptions?.assignment?.disableType"
@value-changed="valueChanged"
/>
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">

View File

@@ -29,6 +29,7 @@ import {
faChartBar,
faCheck,
faCheckCircle,
faCheckDouble,
faCheckSquare,
faChevronDown,
faChevronUp,
@@ -225,6 +226,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faChartBar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faCheckDouble);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);

View File

@@ -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"]
}

View File

@@ -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,
);
});
});
});

View File

@@ -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];
}
}

View File

@@ -504,6 +504,7 @@
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/ExecutionData/ExecutionData.node.js",
"dist/nodes/EvaluationMetrics/EvaluationMetrics.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js",
"dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js",

View File

@@ -1352,6 +1352,8 @@ export type FilterTypeOptions = {
export type AssignmentTypeOptions = Partial<{
hideType?: boolean; // visible by default
defaultType?: FieldType | 'string';
disableType?: boolean; // visible by default
}>;
export type DisplayCondition =