mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:03:14 +08:00
refactor(web): metric section refactor
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user