feat(web): restrictions of evalution workflow available nodes

This commit is contained in:
JzoNg
2026-04-07 16:12:25 +08:00
parent 7c05a68876
commit ed3db06154
16 changed files with 394 additions and 109 deletions

View File

@@ -517,4 +517,24 @@ describe('AppPublisher', () => {
tipKey: 'common.switchToStandardWorkflowTip',
})
})
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
hasHumanInputNode
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
})
})

View File

@@ -185,6 +185,37 @@ describe('app-publisher sections', () => {
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should disable workflow type switch when a disabled reason is provided', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled
workflowTypeSwitchDisabledReason="common.switchToEvaluationWorkflowDisabledTip"
/>,
)
expect(screen.getByRole('button', { name: /common\.publishAsEvaluationWorkflow/i })).toBeDisabled()
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection

View File

@@ -138,6 +138,15 @@ const AppPublisher = ({
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
: undefined
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
const workflowTypeSwitchDisabledReason = useMemo(() => {
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
return undefined
if (!hasHumanInputNode && !hasTriggerNode)
return undefined
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
}, [hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
@@ -228,6 +237,10 @@ const AppPublisher = ({
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
try {
await convertWorkflowType({
@@ -252,7 +265,7 @@ const AppPublisher = ({
setOpen(false)
}
catch { }
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig, workflowTypeSwitchDisabledReason])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
@@ -310,7 +323,8 @@ const AppPublisher = ({
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || Boolean(workflowTypeSwitchDisabledReason)}
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
{!isEvaluationWorkflowType && (

View File

@@ -44,6 +44,7 @@ type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
tipKey: WorkflowTypeSwitchLabelKey
}
workflowTypeSwitchDisabled: boolean
workflowTypeSwitchDisabledReason?: string
}
type AccessSectionProps = {
@@ -100,6 +101,28 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
@@ -117,6 +140,7 @@ export const PublisherSummarySection = ({
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
}: SummarySectionProps) => {
const { t } = useTranslation()
@@ -178,43 +202,45 @@ export const PublisherSummarySection = ({
)}
</Button>
{workflowTypeSwitchConfig && (
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
/>
<TooltipContent
placement="top"
popupClassName="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</button>
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
popupClassName="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</button>
</ActionTooltip>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
@@ -279,28 +305,6 @@ export const PublisherAccessSection = ({
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,

View File

@@ -2,8 +2,21 @@ import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { AppTypeEnum } from '@/types/app'
import WorkflowOnboardingModal from '../index'
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({
appDetail: {
type: mockAppType.current,
},
}),
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
default: function MockNodeSelector({
open,
@@ -44,6 +57,7 @@ describe('WorkflowOnboardingModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
})
const renderComponent = (props = {}) => {
@@ -91,6 +105,15 @@ describe('WorkflowOnboardingModal', () => {
expect(getTriggerHeading()).toBeInTheDocument()
})
it('should hide the trigger starter in evaluation workflows', () => {
mockAppType.current = AppTypeEnum.EVALUATION
renderComponent()
expect(getUserInputHeading()).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'workflow.onboarding.trigger' })).not.toBeInTheDocument()
})
it('should render ESC tip when shown', () => {
renderComponent({ isShow: true })

View File

@@ -2,8 +2,21 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { AppTypeEnum } from '@/types/app'
import StartNodeSelectionPanel from '../start-node-selection-panel'
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({
appDetail: {
type: mockAppType.current,
},
}),
}))
// Mock NodeSelector component
vi.mock('@/app/components/workflow/block-selector', () => ({
default: function MockNodeSelector({
@@ -61,6 +74,7 @@ describe('StartNodeSelectionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
})
// Helper function to render component
@@ -96,6 +110,15 @@ describe('StartNodeSelectionPanel', () => {
expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument()
})
it('should hide the trigger option in evaluation workflows', () => {
mockAppType.current = AppTypeEnum.EVALUATION
renderComponent()
expect(screen.queryByText('workflow.onboarding.trigger')).not.toBeInTheDocument()
expect(screen.queryByTestId('node-selector')).not.toBeInTheDocument()
})
it('should render node selector component', () => {
// Arrange & Act
renderComponent()

View File

@@ -3,10 +3,12 @@ import type { FC } from 'react'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { Home, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import NodeSelector from '@/app/components/workflow/block-selector'
import { TabsEnum } from '@/app/components/workflow/block-selector/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow'
import StartNodeOption from './start-node-option'
type StartNodeSelectionPanelProps = {
@@ -19,7 +21,9 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
onSelectTrigger,
}) => {
const { t } = useTranslation()
const appType = useAppStore(s => s.appDetail?.type)
const [showTriggerSelector, setShowTriggerSelector] = useState(false)
const isEvaluationWorkflowType = isEvaluationWorkflow(appType)
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
setShowTriggerSelector(false)
@@ -39,34 +43,36 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
onClick={onSelectUserInput}
/>
<NodeSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
<StartNodeOption
icon={(
<div className="flex h-9 w-9 items-center justify-center radius-lg border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<TriggerAll className="h-5 w-5 text-white" />
</div>
)}
title={t('onboarding.trigger', { ns: 'workflow' })}
description={t('onboarding.triggerDescription', { ns: 'workflow' })}
onClick={() => setShowTriggerSelector(true)}
/>
)}
/>
{!isEvaluationWorkflowType && (
<NodeSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
<StartNodeOption
icon={(
<div className="flex h-9 w-9 items-center justify-center radius-lg border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<TriggerAll className="h-5 w-5 text-white" />
</div>
)}
title={t('onboarding.trigger', { ns: 'workflow' })}
description={t('onboarding.triggerDescription', { ns: 'workflow' })}
onClick={() => setShowTriggerSelector(true)}
/>
)}
/>
)}
</div>
)
}

View File

@@ -1,13 +1,25 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { AppTypeEnum } from '@/types/app'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
const mockUseIsChatMode = vi.fn()
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({
appDetail: {
type: mockAppType.current,
},
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `/docs${path}`,
}))
@@ -15,6 +27,7 @@ vi.mock('@/context/i18n', () => ({
describe('useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
})
it('should include chat-specific nodes and make the start node undeletable in chat mode', () => {
@@ -46,4 +59,18 @@ describe('useAvailableNodesMetaData', () => {
title: 'workflow.blocks.start',
})
})
it('should exclude human input and trigger nodes in evaluation workflows', () => {
mockUseIsChatMode.mockReturnValue(false)
mockAppType.current = AppTypeEnum.EVALUATION
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.HumanInput]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeUndefined()
})
})

View File

@@ -2,6 +2,7 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-sto
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import AnswerDefault from '@/app/components/workflow/nodes/answer/default'
import EndDefault from '@/app/components/workflow/nodes/end/default'
@@ -10,13 +11,16 @@ import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin
import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default'
import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default'
import { BlockEnum } from '@/app/components/workflow/types'
import { isEvaluationWorkflow, isEvaluationWorkflowRestrictedNodeType } from '@/app/components/workflow/utils/evaluation-workflow'
import { useDocLink } from '@/context/i18n'
import { useIsChatMode } from './use-is-chat-mode'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const appType = useAppStore(s => s.appDetail?.type)
const docLink = useDocLink()
const isEvaluationWorkflowType = isEvaluationWorkflow(appType)
const startNodeMetaData = useMemo(() => ({
...StartDefault,
@@ -26,20 +30,27 @@ export const useAvailableNodesMetaData = () => {
},
}), [isChatMode])
const mergedNodesMetaData = useMemo(() => [
...WORKFLOW_COMMON_NODES,
startNodeMetaData,
...(
isChatMode
? [AnswerDefault]
: [
EndDefault,
TriggerWebhookDefault,
TriggerScheduleDefault,
TriggerPluginDefault,
]
),
], [isChatMode, startNodeMetaData])
const mergedNodesMetaData = useMemo(() => {
const nodes = [
...WORKFLOW_COMMON_NODES,
startNodeMetaData,
...(
isChatMode
? [AnswerDefault]
: [
EndDefault,
TriggerWebhookDefault,
TriggerScheduleDefault,
TriggerPluginDefault,
]
),
]
if (!isEvaluationWorkflowType)
return nodes
return nodes.filter(node => !isEvaluationWorkflowRestrictedNodeType(node.metaData.type))
}, [isChatMode, isEvaluationWorkflowType, startNodeMetaData])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
const { metaData } = node

View File

@@ -8,7 +8,7 @@ import { useGetLanguage, useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { Theme } from '@/types/app'
import { AppTypeEnum, Theme } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
import useNodes from '../../store/workflow/use-nodes'
@@ -19,6 +19,18 @@ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({
appDetail: {
type: mockAppType.current,
},
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
useLocale: vi.fn(),
@@ -179,6 +191,7 @@ const createAvailableNodesMetaData = (): ReturnType<typeof useAvailableNodesMeta
describe('AllStartBlocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseLocale.mockReturnValue('en_US')
@@ -238,6 +251,26 @@ describe('AllStartBlocks', () => {
expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start')
})
it('should hide trigger options in evaluation workflows', async () => {
mockAppType.current = AppTypeEnum.EVALUATION
render(
<AllStartBlocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.Start, BlockEnum.TriggerPlugin]}
allowUserInputSelection
/>,
)
await waitFor(() => {
expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument()
})
expect(screen.queryByText('Provider One')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument()
})
})
// Empty filter states should surface the request-to-community fallback.

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import { useStoreApi } from 'reactflow'
import { AppTypeEnum } from '@/types/app'
import { BlockEnum } from '../../types'
import Blocks from '../blocks'
const mockGetNodes = vi.fn(() => [])
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
vi.mock('reactflow', () => ({
useStoreApi: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({
appDetail: {
type: mockAppType.current,
},
}),
}))
const mockUseStoreApi = vi.mocked(useStoreApi)
describe('Blocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes: mockGetNodes,
}),
} as unknown as ReturnType<typeof useStoreApi>)
})
it('should hide human input in evaluation workflows', () => {
mockAppType.current = AppTypeEnum.EVALUATION
render(
<Blocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.HumanInput, BlockEnum.LLM]}
/>,
)
expect(screen.queryByText('workflow.blocks.human-input')).not.toBeInTheDocument()
expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument()
})
})

View File

@@ -14,9 +14,11 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
@@ -54,13 +56,21 @@ const AllStartBlocks = ({
const { t } = useTranslation()
const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false)
const [hasPluginContent, setHasPluginContent] = useState(false)
const appType = useAppStore(s => s.appDetail?.type)
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
const entryNodeTypes = availableBlocksTypes?.length
? availableBlocksTypes
: ENTRY_NODE_TYPES
const entryNodeTypes = useMemo(() => {
const blockTypes = availableBlocksTypes?.length
? availableBlocksTypes
: [...ENTRY_NODE_TYPES]
if (!isEvaluationWorkflow(appType))
return blockTypes
return filterEvaluationWorkflowRestrictedBlockTypes(blockTypes)
}, [appType, availableBlocksTypes])
const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin)
const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin)
const providerMap = useMemo(() => {
@@ -94,7 +104,8 @@ const AllStartBlocks = ({
const shouldShowFeatured = enableTriggerPlugin
&& enable_marketplace
&& !hasFilter
const shouldShowTriggerListTitle = hasStartBlocksContent || hasPluginContent
const hasTriggerOptions = entryNodeTypes.some(type => type !== BlockEnumValue.Start)
const shouldShowTriggerListTitle = hasTriggerOptions && (hasStartBlocksContent || hasPluginContent)
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
const handleStartBlocksContentChange = useCallback((hasContent: boolean) => {

View File

@@ -8,8 +8,10 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { BLOCK_CLASSIFICATIONS } from './constants'
@@ -29,7 +31,14 @@ const Blocks = ({
}: BlocksProps) => {
const { t } = useTranslation()
const store = useStoreApi()
const appType = useAppStore(s => s.appDetail?.type)
const blocksFromHooks = useBlocks()
const filteredAvailableBlocksTypes = useMemo(() => {
if (!isEvaluationWorkflow(appType))
return availableBlocksTypes
return filterEvaluationWorkflowRestrictedBlockTypes(availableBlocksTypes)
}, [appType, availableBlocksTypes])
// Use external blocks if provided, otherwise fallback to hook-based blocks
const blocks = blocksFromProps || blocksFromHooks.map(block => ({
@@ -57,7 +66,7 @@ const Blocks = ({
return false
}
return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type)
return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && filteredAvailableBlocksTypes.includes(block.metaData.type)
})
return {
@@ -65,7 +74,7 @@ const Blocks = ({
[classification]: list,
}
}, {} as Record<string, typeof blocks>)
}, [blocks, searchText, availableBlocksTypes])
}, [blocks, filteredAvailableBlocksTypes, searchText])
const isEmpty = Object.values(groups).every(list => !list.length)
const renderGroup = useCallback((classification: BlockClassificationEnum) => {

View File

@@ -0,0 +1,20 @@
import { AppTypeEnum } from '@/types/app'
import { BlockEnum, TRIGGER_NODE_TYPES } from '../types'
const EVALUATION_WORKFLOW_RESTRICTED_NODE_TYPES = new Set<string>([
BlockEnum.HumanInput,
...TRIGGER_NODE_TYPES,
])
export const isEvaluationWorkflow = (appType?: string) => appType === AppTypeEnum.EVALUATION
export const isEvaluationWorkflowRestrictedNodeType = (nodeType?: string) => {
if (!nodeType)
return false
return EVALUATION_WORKFLOW_RESTRICTED_NODE_TYPES.has(nodeType)
}
export const filterEvaluationWorkflowRestrictedBlockTypes = (blockTypes: BlockEnum[]) => {
return blockTypes.filter(blockType => !isEvaluationWorkflowRestrictedNodeType(blockType))
}

View File

@@ -216,6 +216,7 @@
"common.setVarValuePlaceholder": "Set variable",
"common.showRunHistory": "Show Run History",
"common.switchToEvaluationWorkflow": "Switch to Evaluation Workflow",
"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.switchToStandardWorkflowTip": "Turns this evaluator back into a standard workflow and restores public Web App access.",

View File

@@ -216,6 +216,7 @@
"common.setVarValuePlaceholder": "设置变量值",
"common.showRunHistory": "显示运行历史",
"common.switchToEvaluationWorkflow": "切换为评测工作流",
"common.switchToEvaluationWorkflowDisabledTip": "评测工作流不支持 Human Input 节点或 Trigger 开始节点。",
"common.switchToEvaluationWorkflowTip": "将当前工作流转换为批量测试用的自定义评测器,并禁用公开 Web App 访问。",
"common.switchToStandardWorkflow": "切换为标准工作流",
"common.switchToStandardWorkflowTip": "将当前评测器转换回标准工作流,并恢复公开 Web App 访问。",