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