refactor(web): metric section refactor

This commit is contained in:
JzoNg
2026-03-31 16:28:48 +08:00
parent 74f87ce152
commit 6bec4f65c9
6 changed files with 237 additions and 97 deletions

View File

@@ -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

View File

@@ -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 (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('metrics.title')}
tooltip={t('metrics.description')}
/>
<div className="mt-2 space-y-3">
{!hasMetrics && (
<div className="flex items-center gap-5 rounded-xl bg-background-section px-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-md">
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
</div>
<div className="text-text-tertiary system-xs-regular">
{t('metrics.description')}
</div>
</div>
)}
{resource.metrics.map(metric => (
<div key={metric.id} className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-text-primary system-sm-semibold">{metric.label}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{metric.description}</div>
<div className="mt-3 flex flex-wrap gap-2">
{metric.badges.map(badge => (
<Badge key={badge} className={badge === 'Workflow' ? 'badge-accent' : ''}>{badge}</Badge>
))}
</div>
{metric.kind === 'builtin' && (
<div className="mt-3 rounded-xl bg-background-default-subtle px-3 py-2">
<div className="text-text-secondary system-2xs-medium-uppercase">{t('metrics.nodesLabel')}</div>
<div className="mt-2 flex flex-wrap gap-2">
{metric.nodeInfoList?.length
? metric.nodeInfoList.map(nodeInfo => (
<Badge key={nodeInfo.node_id} className="badge-accent">
{nodeInfo.title}
</Badge>
))
: (
<span className="text-text-tertiary system-xs-regular">{t('metrics.nodesAll')}</span>
)}
</div>
</div>
)}
</div>
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{metric.kind === 'custom-workflow' && (
<CustomMetricEditor
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)}
</div>
))}
<MetricSelector
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</section>
)
}
export default MetricSection

View File

@@ -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(<MetricSection resourceType={resourceType} resourceId={resourceId} />)
}
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()
})
})
})

View File

@@ -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 (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('metrics.title')}
tooltip={t('metrics.description')}
/>
<div className="mt-2 space-y-3">
{!hasMetrics && <MetricSectionEmptyState description={t('metrics.description')} />}
{resource.metrics.map(metric => (
<MetricCard
key={metric.id}
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
nodesLabel={t('metrics.nodesLabel')}
nodesAllLabel={t('metrics.nodesAll')}
removeLabel={t('metrics.remove')}
/>
))}
<MetricSelector
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</section>
)
}
export default MetricSection

View File

@@ -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 (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-text-primary system-sm-semibold">{metric.label}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{metric.description}</div>
<div className="mt-3 flex flex-wrap gap-2">
{metric.badges.map(badge => (
<Badge key={badge} className={badge === 'Workflow' ? 'badge-accent' : ''}>{badge}</Badge>
))}
</div>
{metric.kind === 'builtin' && (
<div className="mt-3 rounded-xl bg-background-default-subtle px-3 py-2">
<div className="text-text-secondary system-2xs-medium-uppercase">{nodesLabel}</div>
<div className="mt-2 flex flex-wrap gap-2">
{metric.nodeInfoList?.length
? metric.nodeInfoList.map(nodeInfo => (
<Badge key={nodeInfo.node_id} className="badge-accent">
{nodeInfo.title}
</Badge>
))
: (
<span className="text-text-tertiary system-xs-regular">{nodesAllLabel}</span>
)}
</div>
</div>
)}
</div>
<Button
size="small"
variant="ghost"
aria-label={removeLabel}
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{metric.kind === 'custom-workflow' && (
<CustomMetricEditorCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)}
</div>
)
}
export default MetricCard

View File

@@ -0,0 +1,18 @@
type MetricSectionEmptyStateProps = {
description: string
}
const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => {
return (
<div className="flex items-center gap-5 rounded-xl bg-background-section px-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-md">
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
</div>
<div className="text-text-tertiary system-xs-regular">
{description}
</div>
</div>
)
}
export default MetricSectionEmptyState