mirror of
https://github.com/langgenius/dify.git
synced 2026-02-22 19:15:47 +08:00
feat(web): unify node selector availability rules
This commit is contained in:
@ -29,6 +29,8 @@ import { usePipelineTemplate } from './use-pipeline-template'
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
let mockSandboxEnabled = false
|
||||
|
||||
// Mock the workflow store
|
||||
const _mockGetState = vi.fn()
|
||||
const mockUseStore = vi.fn()
|
||||
@ -46,6 +48,16 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
|
||||
features: {
|
||||
sandbox: {
|
||||
enabled: mockSandboxEnabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
@ -91,6 +103,18 @@ vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
metaData: { type: BlockEnum.End },
|
||||
defaultValue: { type: BlockEnum.End },
|
||||
},
|
||||
{
|
||||
metaData: { type: BlockEnum.Command },
|
||||
defaultValue: { type: BlockEnum.Command },
|
||||
},
|
||||
{
|
||||
metaData: { type: BlockEnum.FileUpload },
|
||||
defaultValue: { type: BlockEnum.FileUpload },
|
||||
},
|
||||
{
|
||||
metaData: { type: BlockEnum.HumanInput },
|
||||
defaultValue: { type: BlockEnum.HumanInput },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@ -147,6 +171,10 @@ vi.mock('@/service/workflow', () => ({
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
beforeEach(() => {
|
||||
mockSandboxEnabled = false
|
||||
})
|
||||
|
||||
describe('useConfigsMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -468,6 +496,26 @@ describe('useAvailableNodesMetaData', () => {
|
||||
expect(result.current.nodesMap).toBeDefined()
|
||||
expect(typeof result.current.nodesMap).toBe('object')
|
||||
})
|
||||
|
||||
it('should hide sandbox-only nodes when sandbox is disabled', () => {
|
||||
mockSandboxEnabled = false
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
|
||||
it('should keep sandbox-only nodes hidden even when sandbox is enabled', () => {
|
||||
mockSandboxEnabled = true
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePipelineTemplate', () => {
|
||||
|
||||
@ -3,6 +3,11 @@ import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/compone
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
|
||||
import {
|
||||
buildNodeSelectorAvailabilityContext,
|
||||
filterNodesForSelector,
|
||||
NodeSelectorScene,
|
||||
} from '@/app/components/workflow/constants/node-availability'
|
||||
import dataSourceEmptyDefault from '@/app/components/workflow/nodes/data-source-empty/default'
|
||||
import dataSourceDefault from '@/app/components/workflow/nodes/data-source/default'
|
||||
import knowledgeBaseDefault from '@/app/components/workflow/nodes/knowledge-base/default'
|
||||
@ -12,10 +17,12 @@ import { useDocLink } from '@/context/i18n'
|
||||
export const useAvailableNodesMetaData = () => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
|
||||
scene: NodeSelectorScene.RagPipeline,
|
||||
}), [])
|
||||
|
||||
const mergedNodesMetaData = useMemo(() => [
|
||||
// RAG pipeline doesn't support human-input node temporarily
|
||||
...WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.HumanInput),
|
||||
...filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext),
|
||||
{
|
||||
...dataSourceDefault,
|
||||
defaultValue: {
|
||||
@ -25,7 +32,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
},
|
||||
knowledgeBaseDefault,
|
||||
dataSourceEmptyDefault,
|
||||
] as AvailableNodesMetaData['nodes'], [])
|
||||
] as AvailableNodesMetaData['nodes'], [nodeAvailabilityContext])
|
||||
|
||||
const helpLinkUri = useMemo(() => docLink(
|
||||
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',
|
||||
|
||||
130
web/app/components/sub-graph/components/sub-graph-main.spec.tsx
Normal file
130
web/app/components/sub-graph/components/sub-graph-main.spec.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
|
||||
import { FlowType } from '@/types/common'
|
||||
import SubGraphMain from './sub-graph-main'
|
||||
|
||||
const mockUseAvailableNodesMetaData = vi.fn()
|
||||
const mockUseSetWorkflowVarsWithValue = vi.fn()
|
||||
const mockUseInspectVarsCrudCommon = vi.fn()
|
||||
const mockSetPendingSingleRun = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
InteractionMode: {
|
||||
Subgraph: 'subgraph',
|
||||
},
|
||||
WorkflowWithInnerContext: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-with-inner-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => [],
|
||||
edges: [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: (params: unknown) => mockUseSetWorkflowVarsWithValue(params),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
|
||||
useInspectVarsCrudCommon: (params: unknown) => mockUseInspectVarsCrudCommon(params),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setPendingSingleRun: mockSetPendingSingleRun,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useAvailableNodesMetaData: (flowType: FlowType) => mockUseAvailableNodesMetaData(flowType),
|
||||
}))
|
||||
|
||||
vi.mock('./sub-graph-children', () => ({
|
||||
default: () => <div data-testid="sub-graph-children" />,
|
||||
}))
|
||||
|
||||
const nestedNodeConfig: NestedNodeConfig = {
|
||||
extractor_node_id: 'extractor-1',
|
||||
output_selector: ['extractor-1', 'output'],
|
||||
null_strategy: NULL_STRATEGY.RAISE_ERROR,
|
||||
default_value: '',
|
||||
}
|
||||
|
||||
describe('SubGraphMain flowType wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableNodesMetaData.mockReturnValue({
|
||||
nodes: [],
|
||||
nodesMap: {},
|
||||
})
|
||||
mockUseSetWorkflowVarsWithValue.mockReturnValue({
|
||||
fetchInspectVars: vi.fn(),
|
||||
})
|
||||
mockUseInspectVarsCrudCommon.mockReturnValue({})
|
||||
})
|
||||
|
||||
it('should pass configs map flow type to useAvailableNodesMetaData', () => {
|
||||
render(
|
||||
<SubGraphMain
|
||||
variant="assemble"
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
title="Subgraph"
|
||||
extractorNodeId="extractor-1"
|
||||
configsMap={{
|
||||
flowId: 'pipeline-1',
|
||||
flowType: FlowType.ragPipeline,
|
||||
}}
|
||||
isOpen={false}
|
||||
nestedNodeConfig={nestedNodeConfig}
|
||||
onNestedNodeConfigChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseAvailableNodesMetaData).toHaveBeenCalledWith(FlowType.ragPipeline)
|
||||
expect(mockUseSetWorkflowVarsWithValue).toHaveBeenCalledWith({
|
||||
flowType: FlowType.ragPipeline,
|
||||
flowId: 'pipeline-1',
|
||||
interactionMode: 'subgraph',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to app flow when configs map is missing', () => {
|
||||
render(
|
||||
<SubGraphMain
|
||||
variant="assemble"
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
title="Subgraph"
|
||||
extractorNodeId="extractor-1"
|
||||
isOpen={false}
|
||||
nestedNodeConfig={nestedNodeConfig}
|
||||
onNestedNodeConfigChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUseAvailableNodesMetaData).toHaveBeenCalledWith(FlowType.appFlow)
|
||||
expect(mockUseSetWorkflowVarsWithValue).toHaveBeenCalledWith({
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: '',
|
||||
interactionMode: 'subgraph',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -62,9 +62,9 @@ const SubGraphMain: FC<SubGraphMainProps> = (props) => {
|
||||
const reactFlowStore = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const availableNodesMetaData = useAvailableNodesMetaData()
|
||||
const flowType = configsMap?.flowType ?? FlowType.appFlow
|
||||
const flowId = configsMap?.flowId ?? ''
|
||||
const availableNodesMetaData = useAvailableNodesMetaData(flowType)
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
flowType,
|
||||
flowId,
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useAvailableNodesMetaData } from './use-available-nodes-meta-data'
|
||||
|
||||
let mockSandboxEnabled = false
|
||||
let mockRuntimeType: 'sandboxed' | 'classic' = 'classic'
|
||||
|
||||
function createNodeDefault(type: BlockEnum) {
|
||||
return {
|
||||
metaData: { type },
|
||||
defaultValue: { type },
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
|
||||
features: {
|
||||
sandbox: {
|
||||
enabled: mockSandboxEnabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: { runtime_type: 'sandboxed' | 'classic' } }) => unknown) => selector({
|
||||
appDetail: {
|
||||
runtime_type: mockRuntimeType,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
WORKFLOW_COMMON_NODES: [
|
||||
createNodeDefault(BlockEnum.Agent),
|
||||
createNodeDefault(BlockEnum.Command),
|
||||
createNodeDefault(BlockEnum.FileUpload),
|
||||
createNodeDefault(BlockEnum.HumanInput),
|
||||
createNodeDefault(BlockEnum.VariableAggregator),
|
||||
],
|
||||
}))
|
||||
|
||||
describe('sub-graph/useAvailableNodesMetaData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSandboxEnabled = false
|
||||
mockRuntimeType = 'classic'
|
||||
})
|
||||
|
||||
it('should hide sandbox-only nodes in app flow when sandbox is disabled', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).toContain(BlockEnum.Agent)
|
||||
expect(nodeTypes).toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
|
||||
it('should show sandbox-only nodes in app flow when sandbox feature is enabled', () => {
|
||||
mockSandboxEnabled = true
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Agent)
|
||||
expect(nodeTypes).toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
|
||||
it('should show sandbox-only nodes in app flow when runtime type is sandboxed', () => {
|
||||
mockRuntimeType = 'sandboxed'
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Agent)
|
||||
})
|
||||
|
||||
it('should ignore sandbox flags in rag pipeline flow', () => {
|
||||
mockSandboxEnabled = true
|
||||
mockRuntimeType = 'sandboxed'
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.ragPipeline))
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).toContain(BlockEnum.Agent)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
|
||||
it('should map variable assigner to variable aggregator metadata', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData(FlowType.appFlow))
|
||||
|
||||
expect(result.current.nodesMap[BlockEnum.VariableAggregator]).toBeDefined()
|
||||
expect(result.current.nodesMap[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap[BlockEnum.VariableAggregator])
|
||||
})
|
||||
})
|
||||
@ -2,11 +2,31 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-sto
|
||||
import type { CommonNodeType, NodeDefault, NodeDefaultBase } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
|
||||
import {
|
||||
buildNodeSelectorAvailabilityContext,
|
||||
filterNodesForSelector,
|
||||
NodeSelectorScene,
|
||||
} from '@/app/components/workflow/constants/node-availability'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
export const useAvailableNodesMetaData = () => {
|
||||
export const useAvailableNodesMetaData = (flowType: FlowType = FlowType.appFlow) => {
|
||||
const { t } = useTranslation()
|
||||
const isSandboxFeatureEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
|
||||
const isSandboxRuntime = useAppStore(s => s.appDetail?.runtime_type === 'sandboxed')
|
||||
const scene = useMemo(() => {
|
||||
if (flowType === FlowType.ragPipeline)
|
||||
return NodeSelectorScene.RagPipeline
|
||||
return NodeSelectorScene.Subgraph
|
||||
}, [flowType])
|
||||
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
|
||||
scene,
|
||||
isSandboxRuntime,
|
||||
isSandboxFeatureEnabled,
|
||||
}), [scene, isSandboxFeatureEnabled, isSandboxRuntime])
|
||||
|
||||
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
|
||||
const toNodeDefaultBase = (
|
||||
@ -31,7 +51,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
}
|
||||
}
|
||||
|
||||
return WORKFLOW_COMMON_NODES.map((node) => {
|
||||
return filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext).map((node) => {
|
||||
// normalize per-node defaults into a shared metadata shape.
|
||||
const typedNode = node as NodeDefault<CommonNodeType>
|
||||
const { metaData } = typedNode
|
||||
@ -47,7 +67,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
title,
|
||||
})
|
||||
})
|
||||
}, [t])
|
||||
}, [nodeAvailabilityContext, t])
|
||||
|
||||
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
|
||||
acc![node.metaData.type] = node
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAvailableNodesMetaData } from './use-available-nodes-meta-data'
|
||||
|
||||
let mockIsChatMode = false
|
||||
let mockSandboxEnabled = false
|
||||
let mockRuntimeType: 'sandboxed' | 'classic' = 'classic'
|
||||
|
||||
function createNodeDefault(type: BlockEnum, helpLinkUri?: string) {
|
||||
return {
|
||||
metaData: {
|
||||
type,
|
||||
...(helpLinkUri ? { helpLinkUri } : {}),
|
||||
},
|
||||
defaultValue: { type },
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('./use-is-chat-mode', () => ({
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: { sandbox: { enabled: boolean } } }) => unknown) => selector({
|
||||
features: {
|
||||
sandbox: {
|
||||
enabled: mockSandboxEnabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: { runtime_type: 'sandboxed' | 'classic' } }) => unknown) => selector({
|
||||
appDetail: {
|
||||
runtime_type: mockRuntimeType,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
WORKFLOW_COMMON_NODES: [
|
||||
createNodeDefault(BlockEnum.LLM, 'llm-help'),
|
||||
createNodeDefault(BlockEnum.Agent),
|
||||
createNodeDefault(BlockEnum.Command),
|
||||
createNodeDefault(BlockEnum.FileUpload),
|
||||
createNodeDefault(BlockEnum.VariableAggregator),
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/start/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.Start),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/end/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.End),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/answer/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.Answer),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/trigger-webhook/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.TriggerWebhook),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/trigger-schedule/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.TriggerSchedule),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/trigger-plugin/default', () => ({
|
||||
default: createNodeDefault(BlockEnum.TriggerPlugin),
|
||||
}))
|
||||
|
||||
describe('workflow-app/useAvailableNodesMetaData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = false
|
||||
mockSandboxEnabled = false
|
||||
mockRuntimeType = 'classic'
|
||||
})
|
||||
|
||||
it('should include workflow-only nodes when chat mode is disabled', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
const startNode = result.current.nodes.find(node => node.metaData.type === BlockEnum.Start)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.End)
|
||||
expect(nodeTypes).toContain(BlockEnum.TriggerWebhook)
|
||||
expect(nodeTypes).toContain(BlockEnum.TriggerSchedule)
|
||||
expect(nodeTypes).toContain(BlockEnum.TriggerPlugin)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Answer)
|
||||
expect(startNode?.metaData.isUndeletable).toBe(false)
|
||||
})
|
||||
|
||||
it('should include chatflow-only nodes when chat mode is enabled', () => {
|
||||
mockIsChatMode = true
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
const startNode = result.current.nodes.find(node => node.metaData.type === BlockEnum.Start)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.Answer)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.End)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.TriggerWebhook)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.TriggerSchedule)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.TriggerPlugin)
|
||||
expect(startNode?.metaData.isUndeletable).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide sandbox-only nodes and keep agent when sandbox is disabled', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
const llmNode = result.current.nodesMap[BlockEnum.LLM]
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).toContain(BlockEnum.Agent)
|
||||
expect(llmNode.metaData.title).toBe('blocks.llm')
|
||||
expect(llmNode.metaData.iconType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show sandbox-only nodes and hide agent when sandbox feature is enabled', () => {
|
||||
mockSandboxEnabled = true
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
const llmNode = result.current.nodesMap[BlockEnum.LLM]
|
||||
const fileUploadNode = result.current.nodesMap[BlockEnum.FileUpload]
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Agent)
|
||||
expect(llmNode.metaData.title).toBe('blocks.agent')
|
||||
expect(llmNode.metaData.iconType).toBe(BlockEnum.Agent)
|
||||
expect(llmNode.defaultValue._iconTypeOverride).toBe(BlockEnum.Agent)
|
||||
expect(fileUploadNode.metaData.helpLinkUri).toBe('https://docs.dify.ai/use-dify/nodes/upload-file-to-sandbox')
|
||||
})
|
||||
|
||||
it('should enable sandbox behavior when runtime type is sandboxed', () => {
|
||||
mockRuntimeType = 'sandboxed'
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.Command)
|
||||
expect(nodeTypes).toContain(BlockEnum.FileUpload)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.Agent)
|
||||
})
|
||||
|
||||
it('should map variable assigner to variable aggregator metadata', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodesMap[BlockEnum.VariableAggregator]).toBeDefined()
|
||||
expect(result.current.nodesMap[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap[BlockEnum.VariableAggregator])
|
||||
})
|
||||
})
|
||||
@ -6,6 +6,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
|
||||
import {
|
||||
buildNodeSelectorAvailabilityContext,
|
||||
filterNodesForSelector,
|
||||
NodeSelectorSandboxMode,
|
||||
NodeSelectorScene,
|
||||
} from '@/app/components/workflow/constants/node-availability'
|
||||
import AnswerDefault from '@/app/components/workflow/nodes/answer/default'
|
||||
import EndDefault from '@/app/components/workflow/nodes/end/default'
|
||||
import StartDefault from '@/app/components/workflow/nodes/start/default'
|
||||
@ -20,12 +26,30 @@ const NODE_HELP_LINK_OVERRIDES: Partial<Record<BlockEnum, string>> = {
|
||||
[BlockEnum.FileUpload]: 'upload-file-to-sandbox',
|
||||
}
|
||||
|
||||
type WorkflowAppScene = NodeSelectorScene.Workflow | NodeSelectorScene.Chatflow
|
||||
|
||||
const WORKFLOW_APP_SCENE_NODES = {
|
||||
[NodeSelectorScene.Workflow]: [
|
||||
EndDefault,
|
||||
TriggerWebhookDefault,
|
||||
TriggerScheduleDefault,
|
||||
TriggerPluginDefault,
|
||||
],
|
||||
[NodeSelectorScene.Chatflow]: [AnswerDefault],
|
||||
} as const
|
||||
|
||||
export const useAvailableNodesMetaData = () => {
|
||||
const { t } = useTranslation()
|
||||
const isChatMode = useIsChatMode()
|
||||
const isSandboxFeatureEnabled = useFeatures(s => s.features.sandbox?.enabled) ?? false
|
||||
const isSandboxRuntime = useAppStore(s => s.appDetail?.runtime_type === 'sandboxed')
|
||||
const isSandboxed = isSandboxFeatureEnabled || isSandboxRuntime
|
||||
const scene: WorkflowAppScene = isChatMode ? NodeSelectorScene.Chatflow : NodeSelectorScene.Workflow
|
||||
const nodeAvailabilityContext = useMemo(() => buildNodeSelectorAvailabilityContext({
|
||||
scene,
|
||||
isSandboxRuntime,
|
||||
isSandboxFeatureEnabled,
|
||||
}), [scene, isSandboxFeatureEnabled, isSandboxRuntime])
|
||||
const isSandboxed = nodeAvailabilityContext.sandboxMode === NodeSelectorSandboxMode.Enabled
|
||||
const docLink = useDocLink()
|
||||
|
||||
const startNodeMetaData = useMemo(() => ({
|
||||
@ -36,22 +60,15 @@ export const useAvailableNodesMetaData = () => {
|
||||
},
|
||||
}), [isChatMode])
|
||||
|
||||
const availableWorkflowCommonNodes = useMemo(() => {
|
||||
return filterNodesForSelector(WORKFLOW_COMMON_NODES, nodeAvailabilityContext)
|
||||
}, [nodeAvailabilityContext])
|
||||
|
||||
const mergedNodesMetaData = useMemo(() => [
|
||||
...(isSandboxed
|
||||
? WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.Agent)
|
||||
: WORKFLOW_COMMON_NODES),
|
||||
...availableWorkflowCommonNodes,
|
||||
startNodeMetaData,
|
||||
...(
|
||||
isChatMode
|
||||
? [AnswerDefault]
|
||||
: [
|
||||
EndDefault,
|
||||
TriggerWebhookDefault,
|
||||
TriggerScheduleDefault,
|
||||
TriggerPluginDefault,
|
||||
]
|
||||
),
|
||||
] as AvailableNodesMetaData['nodes'], [isChatMode, isSandboxed, startNodeMetaData])
|
||||
...WORKFLOW_APP_SCENE_NODES[scene],
|
||||
] as AvailableNodesMetaData['nodes'], [availableWorkflowCommonNodes, scene, startNodeMetaData])
|
||||
|
||||
const getHelpLinkSlug = useCallback((nodeType: BlockEnum, helpLinkUri?: string) => {
|
||||
if (isSandboxed && nodeType === BlockEnum.LLM)
|
||||
|
||||
142
web/app/components/workflow/constants/node-availability.spec.ts
Normal file
142
web/app/components/workflow/constants/node-availability.spec.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
buildNodeSelectorAvailabilityContext,
|
||||
filterNodesForSelector,
|
||||
isNodeAvailableInSelector,
|
||||
NodeSelectorSandboxMode,
|
||||
NodeSelectorScene,
|
||||
} from './node-availability'
|
||||
|
||||
type MockNode = {
|
||||
metaData: {
|
||||
type: BlockEnum
|
||||
}
|
||||
}
|
||||
|
||||
describe('node-availability', () => {
|
||||
it('should hide command and file-upload when sandbox is disabled', () => {
|
||||
const mockNodes: MockNode[] = [
|
||||
{ metaData: { type: BlockEnum.Start } },
|
||||
{ metaData: { type: BlockEnum.Command } },
|
||||
{ metaData: { type: BlockEnum.FileUpload } },
|
||||
]
|
||||
|
||||
const result = filterNodesForSelector(mockNodes, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})
|
||||
const nodeTypes = result.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).toEqual([BlockEnum.Start])
|
||||
})
|
||||
|
||||
it('should keep command and file-upload when sandbox is enabled', () => {
|
||||
const mockNodes: MockNode[] = [
|
||||
{ metaData: { type: BlockEnum.Start } },
|
||||
{ metaData: { type: BlockEnum.Command } },
|
||||
{ metaData: { type: BlockEnum.FileUpload } },
|
||||
]
|
||||
|
||||
const result = filterNodesForSelector(mockNodes, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Enabled,
|
||||
})
|
||||
const nodeTypes = result.map(node => node.metaData.type)
|
||||
|
||||
expect(nodeTypes).toEqual([BlockEnum.Start, BlockEnum.Command, BlockEnum.FileUpload])
|
||||
})
|
||||
|
||||
it('should return original reference when no filtering is needed', () => {
|
||||
const mockNodes: MockNode[] = [
|
||||
{ metaData: { type: BlockEnum.Start } },
|
||||
{ metaData: { type: BlockEnum.End } },
|
||||
]
|
||||
|
||||
const result = filterNodesForSelector(mockNodes, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})
|
||||
|
||||
expect(result).toBe(mockNodes)
|
||||
})
|
||||
|
||||
it('should mark command and file-upload as sandbox-only', () => {
|
||||
expect(isNodeAvailableInSelector(BlockEnum.Command, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(false)
|
||||
expect(isNodeAvailableInSelector(BlockEnum.FileUpload, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(false)
|
||||
expect(isNodeAvailableInSelector(BlockEnum.Start, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide agent when sandbox is enabled', () => {
|
||||
expect(isNodeAvailableInSelector(BlockEnum.Agent, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Enabled,
|
||||
})).toBe(false)
|
||||
expect(isNodeAvailableInSelector(BlockEnum.Agent, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide human-input in rag pipeline flow', () => {
|
||||
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
|
||||
scene: NodeSelectorScene.RagPipeline,
|
||||
sandboxMode: NodeSelectorSandboxMode.Enabled,
|
||||
})).toBe(false)
|
||||
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
|
||||
scene: NodeSelectorScene.RagPipeline,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(false)
|
||||
expect(isNodeAvailableInSelector(BlockEnum.HumanInput, {
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
sandboxMode: NodeSelectorSandboxMode.Disabled,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should build unsupported sandbox mode for scenes that do not support sandbox', () => {
|
||||
const context = buildNodeSelectorAvailabilityContext({
|
||||
scene: NodeSelectorScene.RagPipeline,
|
||||
isSandboxRuntime: true,
|
||||
isSandboxFeatureEnabled: true,
|
||||
})
|
||||
|
||||
expect(context.scene).toBe(NodeSelectorScene.RagPipeline)
|
||||
expect(context.sandboxMode).toBe(NodeSelectorSandboxMode.Unsupported)
|
||||
})
|
||||
|
||||
it('should allow explicit scene sandbox support override', () => {
|
||||
const context = buildNodeSelectorAvailabilityContext({
|
||||
scene: NodeSelectorScene.RagPipeline,
|
||||
supportsSandbox: true,
|
||||
isSandboxRuntime: true,
|
||||
isSandboxFeatureEnabled: false,
|
||||
})
|
||||
|
||||
expect(context.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
|
||||
})
|
||||
|
||||
it('should build enabled sandbox mode when runtime or feature enables sandbox', () => {
|
||||
const contextByRuntime = buildNodeSelectorAvailabilityContext({
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
isSandboxRuntime: true,
|
||||
isSandboxFeatureEnabled: false,
|
||||
})
|
||||
const contextByFeature = buildNodeSelectorAvailabilityContext({
|
||||
scene: NodeSelectorScene.Workflow,
|
||||
isSandboxRuntime: false,
|
||||
isSandboxFeatureEnabled: true,
|
||||
})
|
||||
|
||||
expect(contextByRuntime.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
|
||||
expect(contextByFeature.sandboxMode).toBe(NodeSelectorSandboxMode.Enabled)
|
||||
})
|
||||
})
|
||||
115
web/app/components/workflow/constants/node-availability.ts
Normal file
115
web/app/components/workflow/constants/node-availability.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
export enum NodeSelectorScene {
|
||||
Workflow = 'workflow',
|
||||
Chatflow = 'chatflow',
|
||||
RagPipeline = 'rag-pipeline',
|
||||
Subgraph = 'subgraph',
|
||||
}
|
||||
|
||||
export enum NodeSelectorSandboxMode {
|
||||
Enabled = 'enabled',
|
||||
Disabled = 'disabled',
|
||||
Unsupported = 'unsupported',
|
||||
}
|
||||
|
||||
export const NODE_SELECTOR_SCENE_SUPPORTS_SANDBOX: Record<NodeSelectorScene, boolean> = {
|
||||
[NodeSelectorScene.Workflow]: true,
|
||||
[NodeSelectorScene.Chatflow]: true,
|
||||
[NodeSelectorScene.RagPipeline]: false,
|
||||
[NodeSelectorScene.Subgraph]: true,
|
||||
}
|
||||
|
||||
type NodeAvailabilityRule = {
|
||||
sandboxOnly?: boolean
|
||||
hiddenWhenSandboxEnabled?: boolean
|
||||
hiddenInScenes?: NodeSelectorScene[]
|
||||
}
|
||||
|
||||
export const NODE_SELECTOR_AVAILABILITY_RULES: Partial<Record<BlockEnum, NodeAvailabilityRule>> = {
|
||||
[BlockEnum.Command]: { sandboxOnly: true },
|
||||
[BlockEnum.FileUpload]: { sandboxOnly: true },
|
||||
[BlockEnum.Agent]: { hiddenWhenSandboxEnabled: true },
|
||||
[BlockEnum.HumanInput]: { hiddenInScenes: [NodeSelectorScene.RagPipeline] },
|
||||
}
|
||||
|
||||
export type NodeSelectorAvailabilityContext = {
|
||||
scene: NodeSelectorScene
|
||||
sandboxMode: NodeSelectorSandboxMode
|
||||
}
|
||||
|
||||
type BuildNodeSelectorAvailabilityContextProps = {
|
||||
scene: NodeSelectorScene
|
||||
isSandboxRuntime?: boolean
|
||||
isSandboxFeatureEnabled?: boolean
|
||||
supportsSandbox?: boolean
|
||||
}
|
||||
|
||||
const resolveSandboxMode = ({
|
||||
scene,
|
||||
isSandboxRuntime = false,
|
||||
isSandboxFeatureEnabled = false,
|
||||
supportsSandbox = NODE_SELECTOR_SCENE_SUPPORTS_SANDBOX[scene],
|
||||
}: BuildNodeSelectorAvailabilityContextProps): NodeSelectorSandboxMode => {
|
||||
if (!supportsSandbox)
|
||||
return NodeSelectorSandboxMode.Unsupported
|
||||
|
||||
return (isSandboxRuntime || isSandboxFeatureEnabled)
|
||||
? NodeSelectorSandboxMode.Enabled
|
||||
: NodeSelectorSandboxMode.Disabled
|
||||
}
|
||||
|
||||
export const buildNodeSelectorAvailabilityContext = ({
|
||||
scene,
|
||||
isSandboxRuntime,
|
||||
isSandboxFeatureEnabled,
|
||||
supportsSandbox,
|
||||
}: BuildNodeSelectorAvailabilityContextProps): NodeSelectorAvailabilityContext => {
|
||||
return {
|
||||
scene,
|
||||
sandboxMode: resolveSandboxMode({
|
||||
scene,
|
||||
isSandboxRuntime,
|
||||
isSandboxFeatureEnabled,
|
||||
supportsSandbox,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const isNodeAvailableInSelector = (
|
||||
nodeType: BlockEnum,
|
||||
{ scene, sandboxMode }: NodeSelectorAvailabilityContext,
|
||||
) => {
|
||||
const rule = NODE_SELECTOR_AVAILABILITY_RULES[nodeType]
|
||||
if (!rule)
|
||||
return true
|
||||
|
||||
const sandboxEnabled = sandboxMode === NodeSelectorSandboxMode.Enabled
|
||||
if (rule.sandboxOnly && !sandboxEnabled)
|
||||
return false
|
||||
|
||||
if (rule.hiddenWhenSandboxEnabled && sandboxEnabled)
|
||||
return false
|
||||
|
||||
if (rule.hiddenInScenes?.includes(scene))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type NodeLike = {
|
||||
metaData: {
|
||||
type: BlockEnum
|
||||
}
|
||||
}
|
||||
|
||||
export const filterNodesForSelector = <T extends NodeLike>(
|
||||
nodes: T[],
|
||||
context: NodeSelectorAvailabilityContext,
|
||||
) => {
|
||||
const filteredNodes = nodes.filter(node => isNodeAvailableInSelector(node.metaData.type, context))
|
||||
if (filteredNodes.length === nodes.length)
|
||||
return nodes
|
||||
|
||||
return filteredNodes
|
||||
}
|
||||
Reference in New Issue
Block a user