mirror of
https://github.com/langgenius/dify.git
synced 2026-04-07 12:00:38 -04:00
feat(web): restrictions of evalution workflow available nodes
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
20
web/app/components/workflow/utils/evaluation-workflow.ts
Normal file
20
web/app/components/workflow/utils/evaluation-workflow.ts
Normal 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))
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 访问。",
|
||||
|
||||
Reference in New Issue
Block a user