diff --git a/web/app/components/base/ui/select/index.tsx b/web/app/components/base/ui/select/index.tsx index ab04be21ec..964bd79e08 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/web/app/components/base/ui/select/index.tsx @@ -9,8 +9,6 @@ import { parsePlacement } from '@/app/components/base/ui/placement' import { cn } from '@/utils/classnames' export const Select = BaseSelect.Root -export const SelectGroup = BaseSelect.Group -export const SelectGroupLabel = BaseSelect.GroupLabel export const SelectValue = BaseSelect.Value /** @public */ export const SelectGroup = BaseSelect.Group diff --git a/web/app/components/evaluation/__tests__/store.spec.ts b/web/app/components/evaluation/__tests__/store.spec.ts index 83d9fd9de4..698231bf6f 100644 --- a/web/app/components/evaluation/__tests__/store.spec.ts +++ b/web/app/components/evaluation/__tests__/store.spec.ts @@ -24,7 +24,11 @@ describe('evaluation store', () => { expect(initialMetric).toBeDefined() expect(isCustomMetricConfigured(initialMetric!)).toBe(false) - store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id) + store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, { + workflowId: config.workflowOptions[0].id, + workflowAppId: 'custom-workflow-app-id', + workflowName: config.workflowOptions[0].label, + }) store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, { sourceFieldId: config.fieldOptions[0].id, targetVariableId: config.workflowOptions[0].targetVariables[0].id, @@ -32,6 +36,8 @@ describe('evaluation store', () => { const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id) expect(isCustomMetricConfigured(configuredMetric!)).toBe(true) + expect(configuredMetric!.customConfig!.workflowAppId).toBe('custom-workflow-app-id') + expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label) }) it('should add and remove builtin metrics', () => { diff --git a/web/app/components/evaluation/components/custom-metric-editor/__tests__/workflow-selector.spec.tsx b/web/app/components/evaluation/components/custom-metric-editor/__tests__/workflow-selector.spec.tsx new file mode 100644 index 0000000000..3cf8f30b99 --- /dev/null +++ b/web/app/components/evaluation/components/custom-metric-editor/__tests__/workflow-selector.spec.tsx @@ -0,0 +1,158 @@ +import type { ComponentProps } from 'react' +import type { AvailableEvaluationWorkflow } from '@/types/evaluation' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import WorkflowSelector from '../workflow-selector' + +const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn()) +const mockUseInfiniteScroll = vi.hoisted(() => vi.fn()) + +let loadMoreHandler: (() => Promise<{ list: unknown[] }>) | null = null + +vi.mock('@/service/use-evaluation', () => ({ + useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args), +})) + +vi.mock('ahooks', () => ({ + useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args), +})) + +const createWorkflow = ( + overrides: Partial = {}, +): AvailableEvaluationWorkflow => ({ + id: 'workflow-1', + app_id: 'app-1', + app_name: 'Review Workflow App', + type: 'evaluation', + version: '1', + marked_name: 'Review Workflow', + marked_comment: 'Production release', + hash: 'hash-1', + created_by: { + id: 'user-1', + name: 'User One', + email: 'user-one@example.com', + }, + created_at: 1710000000, + updated_by: null, + updated_at: 1710000000, + ...overrides, +}) + +const setupWorkflowQueryMock = (overrides?: { + workflows?: AvailableEvaluationWorkflow[] + hasNextPage?: boolean + isFetchingNextPage?: boolean +}) => { + const fetchNextPage = vi.fn() + + mockUseAvailableEvaluationWorkflows.mockReturnValue({ + data: { + pages: [{ + items: overrides?.workflows ?? [createWorkflow()], + page: 1, + limit: 20, + has_more: overrides?.hasNextPage ?? false, + }], + }, + fetchNextPage, + hasNextPage: overrides?.hasNextPage ?? false, + isFetching: false, + isFetchingNextPage: overrides?.isFetchingNextPage ?? false, + isLoading: false, + }) + + return { fetchNextPage } +} + +const renderWorkflowSelector = (props?: Partial>) => { + return render( + , + ) +} + +describe('WorkflowSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + loadMoreHandler = null + + setupWorkflowQueryMock() + mockUseInfiniteScroll.mockImplementation((handler) => { + loadMoreHandler = handler as () => Promise<{ list: unknown[] }> + }) + }) + + // Cover trigger rendering and selected label fallback. + describe('Rendering', () => { + it('should render the workflow placeholder when value is empty', () => { + renderWorkflowSelector() + + expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })).toBeInTheDocument() + expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument() + }) + + it('should render the selected workflow name from props when value is set', () => { + setupWorkflowQueryMock({ workflows: [] }) + + renderWorkflowSelector({ + value: 'workflow-1', + selectedWorkflowName: 'Saved Review Workflow', + }) + + expect(screen.getByText('Saved Review Workflow')).toBeInTheDocument() + }) + }) + + // Cover opening the popover and choosing one workflow option. + describe('Interactions', () => { + it('should call onSelect with the clicked workflow', async () => { + const onSelect = vi.fn() + + renderWorkflowSelector({ onSelect }) + + fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })) + + const option = await screen.findByRole('option', { name: 'Review Workflow' }) + fireEvent.click(option) + + expect(onSelect).toHaveBeenCalledWith(createWorkflow()) + }) + }) + + // Cover the infinite-scroll callback used by the ScrollArea viewport. + describe('Pagination', () => { + it('should fetch the next page when the load-more callback runs and more pages exist', async () => { + const { fetchNextPage } = setupWorkflowQueryMock({ hasNextPage: true }) + + renderWorkflowSelector() + + await waitFor(() => expect(loadMoreHandler).not.toBeNull()) + + await act(async () => { + await loadMoreHandler?.() + }) + + expect(fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('should not fetch the next page when the current request is already fetching', async () => { + const { fetchNextPage } = setupWorkflowQueryMock({ + hasNextPage: true, + isFetchingNextPage: true, + }) + + renderWorkflowSelector() + + await waitFor(() => expect(loadMoreHandler).not.toBeNull()) + + await act(async () => { + await loadMoreHandler?.() + }) + + expect(fetchNextPage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/evaluation/components/custom-metric-editor-card.tsx b/web/app/components/evaluation/components/custom-metric-editor/index.tsx similarity index 72% rename from web/app/components/evaluation/components/custom-metric-editor-card.tsx rename to web/app/components/evaluation/components/custom-metric-editor/index.tsx index d9caf5c137..e8d6b9f507 100644 --- a/web/app/components/evaluation/components/custom-metric-editor-card.tsx +++ b/web/app/components/evaluation/components/custom-metric-editor/index.tsx @@ -1,6 +1,9 @@ 'use client' -import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../types' +import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../../types' +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' +import type { Node } from '@/app/components/workflow/types' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { @@ -12,10 +15,13 @@ import { SelectTrigger, SelectValue, } from '@/app/components/base/ui/select' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAppWorkflow } from '@/service/use-workflow' import { cn } from '@/utils/classnames' -import { getEvaluationMockConfig } from '../mock' -import { isCustomMetricConfigured, useEvaluationStore } from '../store' -import { groupFieldOptions } from '../utils' +import { getEvaluationMockConfig } from '../../mock' +import { isCustomMetricConfigured, useEvaluationStore } from '../../store' +import { groupFieldOptions } from '../../utils' +import WorkflowSelector from './workflow-selector' type CustomMetricEditorCardProps = EvaluationResourceProps & { metric: EvaluationMetric @@ -29,6 +35,27 @@ type MappingRowProps = { onRemove: () => void } +const getWorkflowTargetVariables = ( + nodes?: Array, +) => { + const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node | undefined + if (!startNode || !Array.isArray(startNode.data.variables)) + return [] + + return startNode.data.variables.map(variable => ({ + id: variable.variable, + label: typeof variable.label === 'string' ? variable.label : variable.variable, + })) +} + +const getWorkflowName = (workflow: { + marked_name?: string + app_name?: string + id: string +}) => { + return workflow.marked_name || workflow.app_name || workflow.id +} + function MappingRow({ resourceType, mapping, @@ -82,12 +109,14 @@ const CustomMetricEditorCard = ({ metric, }: CustomMetricEditorCardProps) => { const { t } = useTranslation('evaluation') - const config = getEvaluationMockConfig(resourceType) const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow) const addCustomMetricMapping = useEvaluationStore(state => state.addCustomMetricMapping) const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping) const removeCustomMetricMapping = useEvaluationStore(state => state.removeCustomMetricMapping) - const selectedWorkflow = config.workflowOptions.find(option => option.id === metric.customConfig?.workflowId) + const { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '') + const targetOptions = useMemo(() => { + return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes) + }, [selectedWorkflow?.graph.nodes]) const isConfigured = isCustomMetricConfigured(metric) if (!metric.customConfig) @@ -95,27 +124,15 @@ const CustomMetricEditorCard = ({ return (
- + setCustomMetricWorkflow(resourceType, resourceId, metric.id, { + workflowId: workflow.id, + workflowAppId: workflow.app_id, + workflowName: getWorkflowName(workflow), + })} + />
@@ -136,7 +153,7 @@ const CustomMetricEditorCard = ({ key={mapping.id} resourceType={resourceType} mapping={mapping} - targetOptions={selectedWorkflow?.targetVariables ?? []} + targetOptions={targetOptions} onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)} onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)} /> diff --git a/web/app/components/evaluation/components/custom-metric-editor/workflow-selector.tsx b/web/app/components/evaluation/components/custom-metric-editor/workflow-selector.tsx new file mode 100644 index 0000000000..b9e6b7dd9e --- /dev/null +++ b/web/app/components/evaluation/components/custom-metric-editor/workflow-selector.tsx @@ -0,0 +1,213 @@ +'use client' + +import type { AvailableEvaluationWorkflow } from '@/types/evaluation' +import { useInfiniteScroll } from 'ahooks' +import * as React from 'react' +import { useDeferredValue, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Input from '@/app/components/base/input' +import Loading from '@/app/components/base/loading' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@/app/components/base/ui/scroll-area' +import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation' +import { cn } from '@/utils/classnames' + +type WorkflowSelectorProps = { + value: string | null + selectedWorkflowName?: string | null + onSelect: (workflow: AvailableEvaluationWorkflow) => void +} + +const PAGE_SIZE = 20 + +const getWorkflowName = (workflow: AvailableEvaluationWorkflow) => { + return workflow.marked_name || workflow.app_name || workflow.id +} + +const WorkflowSelector = ({ + value, + selectedWorkflowName, + onSelect, +}: WorkflowSelectorProps) => { + const { t } = useTranslation('evaluation') + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const deferredSearchText = useDeferredValue(searchText) + const viewportRef = useRef(null) + + const keyword = deferredSearchText.trim() || undefined + + const { + data, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isLoading, + } = useAvailableEvaluationWorkflows( + { + page: 1, + limit: PAGE_SIZE, + keyword, + }, + { enabled: isOpen }, + ) + + const workflows = useMemo(() => { + return (data?.pages ?? []).flatMap(page => page.items) + }, [data?.pages]) + + const currentWorkflowName = useMemo(() => { + if (!value) + return null + + const selectedWorkflow = workflows.find(workflow => workflow.id === value) + if (selectedWorkflow) + return getWorkflowName(selectedWorkflow) + + return selectedWorkflowName ?? null + }, [selectedWorkflowName, value, workflows]) + + const isNoMore = hasNextPage === false + + useInfiniteScroll( + async () => { + if (!hasNextPage || isFetchingNextPage) + return { list: [] } + + await fetchNextPage() + return { list: [] } + }, + { + target: viewportRef, + isNoMore: () => isNoMore, + reloadDeps: [isFetchingNextPage, isNoMore, keyword], + }, + ) + + const handleOpenChange = (nextOpen: boolean) => { + setIsOpen(nextOpen) + + if (!nextOpen) + setSearchText('') + } + + return ( + + +
+
+
+
+
+
+
+ {currentWorkflowName ?? t('metrics.custom.workflowPlaceholder')} +
+
+
+ + + + )} + /> + + +
+
+ setSearchText(event.target.value)} + onClear={() => setSearchText('')} + /> +
+ + {(isLoading || (isFetching && workflows.length === 0)) + ? ( +
+ +
+ ) + : !workflows.length + ? ( +
+ {t('noData', { ns: 'common' })} +
+ ) + : ( + + + + {workflows.map(workflow => ( + + ))} + + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ + + +
+ )} +
+
+
+ ) +} + +export default React.memo(WorkflowSelector) 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 index 36ee1fae1c..7b1cb56daf 100644 --- a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx @@ -1,11 +1,14 @@ import { act, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import MetricSection from '..' import { useEvaluationStore } from '../../../store' +const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn()) const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn()) const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn()) vi.mock('@/service/use-evaluation', () => ({ + useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args), useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args), useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args), })) @@ -14,7 +17,19 @@ const resourceType = 'workflow' as const const resourceId = 'metric-section-resource' const renderMetricSection = () => { - return render() + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + + , + ) } describe('MetricSection', () => { @@ -29,6 +44,17 @@ describe('MetricSection', () => { isLoading: false, }) + mockUseAvailableEvaluationWorkflows.mockReturnValue({ + data: { + pages: [{ items: [], page: 1, limit: 20, has_more: false }], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + }) + mockUseEvaluationNodeInfoMutation.mockReturnValue({ isPending: false, mutate: (_input: unknown, options?: { onSuccess?: (data: Record>) => void }) => { diff --git a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx index cccbb43b49..8bdba64d21 100644 --- a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx +++ b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx @@ -114,6 +114,7 @@ const BuiltinMetricCard = ({ render={(