feat(web): switch warining dialog

This commit is contained in:
JzoNg
2026-04-10 18:13:13 +08:00
parent 79fc352a5a
commit 23291398ec
9 changed files with 407 additions and 26 deletions

View File

@@ -16,6 +16,7 @@ const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockConvertWorkflowType = vi.fn()
const mockRefetchEvaluationWorkflowAssociatedTargets = vi.fn()
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
@@ -27,6 +28,7 @@ const ahooksMocks = vi.hoisted(() => ({
}))
let mockAppDetail: Record<string, any> | null = null
let mockEvaluationWorkflowAssociatedTargets: Record<string, any> | undefined
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -96,6 +98,14 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationWorkflowAssociatedTargets: () => ({
data: mockEvaluationWorkflowAssociatedTargets,
refetch: mockRefetchEvaluationWorkflowAssociatedTargets,
isFetching: false,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
@@ -198,6 +208,11 @@ describe('AppPublisher', () => {
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockEvaluationWorkflowAssociatedTargets = { items: [] }
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValue({
data: { items: [] },
isError: false,
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
@@ -518,6 +533,106 @@ describe('AppPublisher', () => {
})
})
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
mockEvaluationWorkflowAssociatedTargets = {
items: [
{
target_type: 'app',
target_id: 'dependent-app-1',
target_name: 'Dependent App',
},
{
target_type: 'knowledge_base',
target_id: 'knowledge-1',
target_name: 'Knowledge Base',
},
],
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: mockEvaluationWorkflowAssociatedTargets,
isError: false,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(screen.getByText('Dependent App')).toBeInTheDocument()
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.switchToStandardWorkflowConfirm.switch' }))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
})
})
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
})
expect(screen.queryByText('common.switchToStandardWorkflowConfirm.title')).not.toBeInTheDocument()
})
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: undefined,
isError: true,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToStandardWorkflowConfirm.loadFailed')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
render(
<AppPublisher

View File

@@ -0,0 +1,158 @@
'use client'
import type { EvaluationWorkflowAssociatedTarget, EvaluationWorkflowAssociatedTargetType } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import Link from '@/next/link'
import { cn } from '@/utils/classnames'
type EvaluationWorkflowSwitchConfirmDialogProps = {
open: boolean
targets: EvaluationWorkflowAssociatedTarget[]
loading?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
const TARGET_TYPE_META: Record<EvaluationWorkflowAssociatedTargetType, {
icon: string
iconClassName: string
labelKey: I18nKeysWithPrefix<'workflow', 'common.switchToStandardWorkflowConfirm.targetTypes.'>
href: (targetId: string) => string
}> = {
app: {
icon: 'i-ri-flow-chart',
iconClassName: 'bg-components-icon-bg-teal-soft text-util-colors-teal-teal-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.app',
href: targetId => `/app/${targetId}/workflow`,
},
snippets: {
icon: 'i-ri-edit-2-line',
iconClassName: 'bg-components-icon-bg-violet-soft text-util-colors-violet-violet-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.snippets',
href: targetId => `/snippets/${targetId}/orchestrate`,
},
knowledge_base: {
icon: 'i-ri-book-2-line',
iconClassName: 'bg-components-icon-bg-indigo-soft text-util-colors-blue-blue-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.knowledge_base',
href: targetId => `/datasets/${targetId}/documents`,
},
}
const getTargetMeta = (targetType: EvaluationWorkflowAssociatedTargetType) => {
return TARGET_TYPE_META[targetType] ?? TARGET_TYPE_META.app
}
const DependentTargetItem = ({
target,
}: {
target: EvaluationWorkflowAssociatedTarget
}) => {
const { t } = useTranslation()
const meta = getTargetMeta(target.target_type)
const targetName = target.target_name || target.target_id
return (
<Link
href={meta.href(target.target_id)}
className="group flex w-full items-center gap-3 rounded-lg bg-background-section p-2 hover:bg-background-section-burn"
title={targetName}
target="_blank"
rel="noreferrer"
>
<span
aria-hidden="true"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular',
meta.iconClassName,
)}
>
<span className={cn(meta.icon, 'size-5')} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 py-px">
<span className="truncate system-md-semibold text-text-secondary">
{targetName}
</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t(meta.labelKey, { ns: 'workflow' })}
</span>
</span>
<span
aria-hidden="true"
className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100"
/>
</Link>
)
}
const EvaluationWorkflowSwitchConfirmDialog = ({
open,
targets,
loading = false,
onOpenChange,
onConfirm,
}: EvaluationWorkflowSwitchConfirmDialogProps) => {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-[480px]">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular text-text-secondary">
<span className="block">
{t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })}
</span>
<span className="block">
{t('common.switchToStandardWorkflowConfirm.description', { ns: 'workflow' })}
</span>
</AlertDialogDescription>
</div>
<div className="flex flex-col gap-2 px-6 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 system-xs-medium-uppercase text-text-quaternary">
{t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })}
</span>
<span className="h-px min-w-0 flex-1 bg-divider-subtle" />
</div>
<div className="flex max-h-[188px] flex-col gap-1 overflow-y-auto">
{targets.map(target => (
<DependentTargetItem
key={`${target.target_type}:${target.target_id}`}
target={target}
/>
))}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={loading}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={loading}
disabled={loading}
onClick={onConfirm}
>
{t('common.switchToStandardWorkflowConfirm.switch', { ns: 'workflow' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default EvaluationWorkflowSwitchConfirmDialog

View File

@@ -1,5 +1,6 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { EvaluationWorkflowAssociatedTarget } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
@@ -28,11 +29,13 @@ import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/acces
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { toast } from '../../base/ui/toast'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import EvaluationWorkflowSwitchConfirmDialog from './evaluation-workflow-switch-confirm-dialog'
import {
PublisherAccessSection,
PublisherActionsSection,
@@ -122,6 +125,8 @@ const AppPublisher = ({
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false)
const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState<EvaluationWorkflowAssociatedTarget[]>([])
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
@@ -138,6 +143,10 @@ const AppPublisher = ({
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
: undefined
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
const {
refetch: refetchEvaluationWorkflowAssociatedTargets,
isFetching: isFetchingEvaluationWorkflowAssociatedTargets,
} = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false })
const workflowTypeSwitchDisabledReason = useMemo(() => {
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
return undefined
@@ -234,13 +243,9 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handleWorkflowTypeSwitch = useCallback(async () => {
const performWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
return false
try {
await convertWorkflowType({
@@ -263,9 +268,57 @@ const AppPublisher = ({
if (publishedAt)
setOpen(false)
setShowEvaluationWorkflowSwitchConfirm(false)
setEvaluationWorkflowSwitchTargets([])
return true
}
catch { }
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig, workflowTypeSwitchDisabledReason])
catch {
return false
}
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
if (appDetail.type === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets()
if (associatedTargetsResult.isError) {
toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' }))
return
}
const associatedTargets = associatedTargetsResult.data?.items ?? []
if (associatedTargets.length > 0) {
setEvaluationWorkflowSwitchTargets(associatedTargets)
setShowEvaluationWorkflowSwitchConfirm(true)
return
}
}
await performWorkflowTypeSwitch()
}, [
appDetail?.id,
appDetail?.type,
performWorkflowTypeSwitch,
refetchEvaluationWorkflowAssociatedTargets,
t,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabledReason,
])
const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => {
setShowEvaluationWorkflowSwitchConfirm(nextOpen)
if (!nextOpen)
setEvaluationWorkflowSwitchTargets([])
}, [])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
@@ -323,7 +376,7 @@ const AppPublisher = ({
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || Boolean(workflowTypeSwitchDisabledReason)}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || isFetchingEvaluationWorkflowAssociatedTargets || Boolean(workflowTypeSwitchDisabledReason)}
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
@@ -372,6 +425,13 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</PortalToFollowElem>
<EvaluationWorkflowSwitchConfirmDialog
open={showEvaluationWorkflowSwitchConfirm}
targets={evaluationWorkflowSwitchTargets}
loading={isConvertingWorkflowType}
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
onConfirm={() => void performWorkflowTypeSwitch()}
/>
</>
)
}

View File

@@ -13,6 +13,7 @@ import type {
EvaluationRunRequest,
EvaluationTargetType,
EvaluationVersionDetailResponse,
EvaluationWorkflowAssociatedTargetsResponse,
} from '@/types/evaluation'
import { type } from '@orpc/contract'
import { base } from '../base'
@@ -290,6 +291,18 @@ export const availableEvaluationWorkflowsContract = base
}>())
.output(type<AvailableEvaluationWorkflowsResponse>())
export const evaluationWorkflowAssociatedTargetsContract = base
.route({
path: '/workspaces/current/evaluation-workflows/{workflowId}/associated-targets',
method: 'GET',
})
.input(type<{
params: {
workflowId: string
}
}>())
.output(type<EvaluationWorkflowAssociatedTargetsResponse>())
export const evaluationFileContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/files/{fileId}',

View File

@@ -20,6 +20,7 @@ import {
evaluationRunDetailContract,
evaluationTemplateDownloadContract,
evaluationVersionDetailContract,
evaluationWorkflowAssociatedTargetsContract,
saveDatasetEvaluationConfigContract,
saveEvaluationConfigContract,
startDatasetEvaluationRunContract,
@@ -134,6 +135,7 @@ export const consoleRouterContract = {
nodeInfo: evaluationNodeInfoContract,
availableMetrics: availableEvaluationMetricsContract,
availableWorkflows: availableEvaluationWorkflowsContract,
associatedTargets: evaluationWorkflowAssociatedTargetsContract,
file: evaluationFileContract,
versionDetail: evaluationVersionDetailContract,
},

View File

@@ -587,9 +587,6 @@
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
@@ -5556,6 +5553,11 @@
"count": 4
}
},
"app/components/evaluation/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/explore/app-card/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
@@ -5566,11 +5568,6 @@
"count": 2
}
},
"app/components/evaluation/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"app/components/explore/banner/banner-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -7665,13 +7662,13 @@
"count": 1
}
},
"app/components/splash.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"app/components/snippets/components/snippet-main.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/snippets/components/snippet-main.tsx": {
"ts/no-explicit-any": {
"app/components/splash.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
@@ -11915,11 +11912,6 @@
"count": 7
}
},
"service/use-evaluation.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"service/use-flow.ts": {
"react/no-unnecessary-use-prefix": {
"count": 1

View File

@@ -219,6 +219,16 @@
"common.switchToEvaluationWorkflowDisabledTip": "Evaluation workflows do not support Human Input nodes or Trigger start nodes.",
"common.switchToEvaluationWorkflowTip": "Turns this workflow into a custom evaluator for batch testing. Disables public Web App access.",
"common.switchToStandardWorkflow": "Switch to Standard Workflow",
"common.switchToStandardWorkflowConfirm.activeIn_one": "This evaluator is currently active in {{count}} configuration.",
"common.switchToStandardWorkflowConfirm.activeIn_other": "This evaluator is currently active in {{count}} configurations.",
"common.switchToStandardWorkflowConfirm.dependentWorkflows": "Dependent workflows",
"common.switchToStandardWorkflowConfirm.description": "Switching to a standard workflow will break these dependencies and may cause active batch tests to fail.",
"common.switchToStandardWorkflowConfirm.loadFailed": "Failed to load dependent workflows.",
"common.switchToStandardWorkflowConfirm.switch": "Switch",
"common.switchToStandardWorkflowConfirm.targetTypes.app": "Workflow",
"common.switchToStandardWorkflowConfirm.targetTypes.knowledge_base": "Knowledge Base",
"common.switchToStandardWorkflowConfirm.targetTypes.snippets": "Snippet",
"common.switchToStandardWorkflowConfirm.title": "Switch to Standard Workflow?",
"common.switchToStandardWorkflowTip": "Turns this evaluator back into a standard workflow and restores public Web App access.",
"common.syncingData": "Syncing data, just a few seconds.",
"common.tagBound": "Number of apps using this tag",

View File

@@ -2,6 +2,7 @@ import type { EvaluationResourceType } from '@/app/components/evaluation/types'
import type { AvailableEvaluationWorkflowsResponse, EvaluationConfig } from '@/types/evaluation'
import {
keepPreviousData,
skipToken,
useInfiniteQuery,
useMutation,
useQuery,
@@ -59,6 +60,24 @@ export const useAvailableEvaluationMetrics = (enabled = true) => {
}))
}
export const useEvaluationWorkflowAssociatedTargets = (
workflowId: string | undefined,
options?: { enabled?: boolean },
) => {
return useQuery(consoleQuery.evaluation.associatedTargets.queryOptions({
input: workflowId
? {
params: {
workflowId,
},
}
: skipToken,
enabled: Boolean(workflowId) && (options?.enabled ?? true),
refetchOnWindowFocus: false,
retry: false,
}))
}
export const useEvaluationNodeInfoMutation = () => {
return useMutation(consoleQuery.evaluation.nodeInfo.mutationOptions())
}

View File

@@ -167,6 +167,18 @@ export type AvailableEvaluationWorkflowsResponse = {
has_more: boolean
}
export type EvaluationWorkflowAssociatedTargetType = 'app' | 'snippets' | 'knowledge_base'
export type EvaluationWorkflowAssociatedTarget = {
target_type: EvaluationWorkflowAssociatedTargetType
target_id: string
target_name: string
}
export type EvaluationWorkflowAssociatedTargetsResponse = {
items: EvaluationWorkflowAssociatedTarget[]
}
export type EvaluationNodeInfoRequest = {
metrics?: string[]
}