diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue index 96f940711e..5e9512fb21 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue @@ -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 => { diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts index 39a9638fa0..43c31c1e0f 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts @@ -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'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue index 213269ab2f..6d0d3bfcbd 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue @@ -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(), { isReadOnly: false }); +const props = withDefaults(defineProps(), { + 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)" > diff --git a/packages/frontend/editor-ui/src/components/ParameterInputList.vue b/packages/frontend/editor-ui/src/components/ParameterInputList.vue index 740e4de47c..8c625b348d 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInputList.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInputList.vue @@ -665,6 +665,8 @@ function getParameterValue
diff --git a/packages/frontend/editor-ui/src/plugins/icons/index.ts b/packages/frontend/editor-ui/src/plugins/icons/index.ts index cb526515c4..ef2d11a2cc 100644 --- a/packages/frontend/editor-ui/src/plugins/icons/index.ts +++ b/packages/frontend/editor-ui/src/plugins/icons/index.ts @@ -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); diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json new file mode 100644 index 0000000000..19343f021c --- /dev/null +++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json @@ -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"] +} diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts new file mode 100644 index 0000000000..9d5e215520 --- /dev/null +++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts @@ -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(); + 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, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts new file mode 100644 index 0000000000..2ca86b626c --- /dev/null +++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts @@ -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. More Info", + 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 { + 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]; + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 715f4afaa8..beb4670c92 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 426d08d34f..4244feb6ca 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -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 =