diff --git a/web/app/components/evaluation/components/custom-metric-editor.tsx b/web/app/components/evaluation/components/custom-metric-editor-card.tsx similarity index 97% rename from web/app/components/evaluation/components/custom-metric-editor.tsx rename to web/app/components/evaluation/components/custom-metric-editor-card.tsx index 2c35566bddf..3bc1db061bf 100644 --- a/web/app/components/evaluation/components/custom-metric-editor.tsx +++ b/web/app/components/evaluation/components/custom-metric-editor-card.tsx @@ -17,7 +17,7 @@ import { getEvaluationMockConfig } from '../mock' import { isCustomMetricConfigured, useEvaluationStore } from '../store' import { groupFieldOptions } from '../utils' -type CustomMetricEditorProps = EvaluationResourceProps & { +type CustomMetricEditorCardProps = EvaluationResourceProps & { metric: EvaluationMetric } @@ -76,11 +76,11 @@ function MappingRow({ ) } -const CustomMetricEditor = ({ +const CustomMetricEditorCard = ({ resourceType, resourceId, metric, -}: CustomMetricEditorProps) => { +}: CustomMetricEditorCardProps) => { const { t } = useTranslation('evaluation') const config = getEvaluationMockConfig(resourceType) const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow) @@ -148,4 +148,4 @@ const CustomMetricEditor = ({ ) } -export default CustomMetricEditor +export default CustomMetricEditorCard diff --git a/web/app/components/evaluation/components/metric-section.tsx b/web/app/components/evaluation/components/metric-section.tsx deleted file mode 100644 index 704b432ca31..00000000000 --- a/web/app/components/evaluation/components/metric-section.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client' - -import type { EvaluationResourceProps } from '../types' -import { useTranslation } from 'react-i18next' -import Badge from '@/app/components/base/badge' -import Button from '@/app/components/base/button' -import { useEvaluationResource, useEvaluationStore } from '../store' -import CustomMetricEditor from './custom-metric-editor' -import MetricSelector from './metric-selector' -import { InlineSectionHeader } from './section-header' - -const MetricSection = ({ - resourceType, - resourceId, -}: EvaluationResourceProps) => { - const { t } = useTranslation('evaluation') - const resource = useEvaluationResource(resourceType, resourceId) - const removeMetric = useEvaluationStore(state => state.removeMetric) - const hasMetrics = resource.metrics.length > 0 - - return ( -
- -
- {!hasMetrics && ( -
-
-
-
- {t('metrics.description')} -
-
- )} - {resource.metrics.map(metric => ( -
-
-
-
{metric.label}
-
{metric.description}
-
- {metric.badges.map(badge => ( - {badge} - ))} -
- {metric.kind === 'builtin' && ( -
-
{t('metrics.nodesLabel')}
-
- {metric.nodeInfoList?.length - ? metric.nodeInfoList.map(nodeInfo => ( - - {nodeInfo.title} - - )) - : ( - {t('metrics.nodesAll')} - )} -
-
- )} -
- -
- {metric.kind === 'custom-workflow' && ( - - )} -
- ))} - -
-
- ) -} - -export default MetricSection diff --git a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx new file mode 100644 index 00000000000..b112d76f474 --- /dev/null +++ b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import MetricSection from '..' +import { useEvaluationStore } from '../../../store' + +const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn()) +const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/use-evaluation', () => ({ + useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args), + useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args), +})) + +const resourceType = 'workflow' as const +const resourceId = 'metric-section-resource' + +const renderMetricSection = () => { + return render() +} + +describe('MetricSection', () => { + beforeEach(() => { + vi.clearAllMocks() + useEvaluationStore.setState({ resources: {} }) + + mockUseAvailableEvaluationMetrics.mockReturnValue({ + data: { + metrics: ['answer-correctness'], + }, + isLoading: false, + }) + + mockUseEvaluationNodeInfoMutation.mockReturnValue({ + isPending: false, + mutate: vi.fn(), + }) + }) + + // Verify the empty state block extracted from MetricSection. + describe('Empty State', () => { + it('should render the metric empty state when no metrics are selected', () => { + renderMetricSection() + + expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'evaluation.metrics.add' })).toBeInTheDocument() + }) + }) + + // Verify the extracted builtin metric card presentation and removal flow. + describe('Builtin Metric Card', () => { + it('should render node badges for a builtin metric and remove it when delete is clicked', () => { + act(() => { + useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [ + { node_id: 'node-answer', title: 'Answer Node', type: 'llm' }, + ]) + }) + + renderMetricSection() + + expect(screen.getByText('Answer Correctness')).toBeInTheDocument() + expect(screen.getByText('Answer Node')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.remove' })) + + expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument() + expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument() + }) + + it('should render the all-nodes label when a builtin metric has no node selection', () => { + act(() => { + useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', []) + }) + + renderMetricSection() + + expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument() + }) + }) + + // Verify the extracted custom metric editor card renders inside the metric card. + describe('Custom Metric Card', () => { + it('should render the custom metric editor card when a custom metric is added', () => { + act(() => { + useEvaluationStore.getState().addCustomMetric(resourceType, resourceId) + }) + + renderMetricSection() + + expect(screen.getByText('Custom Evaluator')).toBeInTheDocument() + expect(screen.getByText('evaluation.metrics.custom.title')).toBeInTheDocument() + expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.addMapping' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/evaluation/components/metric-section/index.tsx b/web/app/components/evaluation/components/metric-section/index.tsx new file mode 100644 index 00000000000..582fcbfa301 --- /dev/null +++ b/web/app/components/evaluation/components/metric-section/index.tsx @@ -0,0 +1,47 @@ +'use client' + +import type { EvaluationResourceProps } from '../../types' +import { useTranslation } from 'react-i18next' +import { useEvaluationResource } from '../../store' +import MetricSelector from '../metric-selector' +import { InlineSectionHeader } from '../section-header' +import MetricCard from './metric-card' +import MetricSectionEmptyState from './metric-section-empty-state' + +const MetricSection = ({ + resourceType, + resourceId, +}: EvaluationResourceProps) => { + const { t } = useTranslation('evaluation') + const resource = useEvaluationResource(resourceType, resourceId) + const hasMetrics = resource.metrics.length > 0 + + return ( +
+ +
+ {!hasMetrics && } + {resource.metrics.map(metric => ( + + ))} + +
+
+ ) +} + +export default MetricSection diff --git a/web/app/components/evaluation/components/metric-section/metric-card.tsx b/web/app/components/evaluation/components/metric-section/metric-card.tsx new file mode 100644 index 00000000000..70af26a2dce --- /dev/null +++ b/web/app/components/evaluation/components/metric-section/metric-card.tsx @@ -0,0 +1,74 @@ +'use client' + +import type { EvaluationMetric, EvaluationResourceProps } from '../../types' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import { useEvaluationStore } from '../../store' +import CustomMetricEditorCard from '../custom-metric-editor-card' + +type MetricCardProps = EvaluationResourceProps & { + metric: EvaluationMetric + nodesLabel: string + nodesAllLabel: string + removeLabel: string +} + +const MetricCard = ({ + resourceType, + resourceId, + metric, + nodesLabel, + nodesAllLabel, + removeLabel, +}: MetricCardProps) => { + const removeMetric = useEvaluationStore(state => state.removeMetric) + + return ( +
+
+
+
{metric.label}
+
{metric.description}
+
+ {metric.badges.map(badge => ( + {badge} + ))} +
+ {metric.kind === 'builtin' && ( +
+
{nodesLabel}
+
+ {metric.nodeInfoList?.length + ? metric.nodeInfoList.map(nodeInfo => ( + + {nodeInfo.title} + + )) + : ( + {nodesAllLabel} + )} +
+
+ )} +
+ +
+ {metric.kind === 'custom-workflow' && ( + + )} +
+ ) +} + +export default MetricCard diff --git a/web/app/components/evaluation/components/metric-section/metric-section-empty-state.tsx b/web/app/components/evaluation/components/metric-section/metric-section-empty-state.tsx new file mode 100644 index 00000000000..657cdcbdd1b --- /dev/null +++ b/web/app/components/evaluation/components/metric-section/metric-section-empty-state.tsx @@ -0,0 +1,18 @@ +type MetricSectionEmptyStateProps = { + description: string +} + +const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => { + return ( +
+
+
+
+ {description} +
+
+ ) +} + +export default MetricSectionEmptyState