mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
feat: introduce trigger functionality (#27644)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: Stream <Stream_2@qq.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do> Co-authored-by: Harry <xh001x@hotmail.com> Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: yessenia <yessenia.contact@gmail.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WTW0313 <twwu@dify.ai> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,293 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTriggerStatusStore } from '../trigger-status'
|
||||
import type { EntryNodeStatus } from '../trigger-status'
|
||||
|
||||
describe('useTriggerStatusStore', () => {
|
||||
beforeEach(() => {
|
||||
// Clear the store state before each test
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
act(() => {
|
||||
result.current.clearTriggerStatuses()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with empty trigger statuses', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual({})
|
||||
})
|
||||
|
||||
it('should return "disabled" for non-existent trigger status', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
const status = result.current.getTriggerStatus('non-existent-id')
|
||||
expect(status).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTriggerStatus', () => {
|
||||
it('should set trigger status for a single node', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'enabled')
|
||||
})
|
||||
|
||||
expect(result.current.triggerStatuses['node-1']).toBe('enabled')
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('enabled')
|
||||
})
|
||||
|
||||
it('should update existing trigger status', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
// Set initial status
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'enabled')
|
||||
})
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('enabled')
|
||||
|
||||
// Update status
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'disabled')
|
||||
})
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('disabled')
|
||||
})
|
||||
|
||||
it('should handle multiple nodes independently', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'enabled')
|
||||
result.current.setTriggerStatus('node-2', 'disabled')
|
||||
})
|
||||
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('enabled')
|
||||
expect(result.current.getTriggerStatus('node-2')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTriggerStatuses', () => {
|
||||
it('should set multiple trigger statuses at once', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
const statuses = {
|
||||
'node-1': 'enabled' as EntryNodeStatus,
|
||||
'node-2': 'disabled' as EntryNodeStatus,
|
||||
'node-3': 'enabled' as EntryNodeStatus,
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses(statuses)
|
||||
})
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual(statuses)
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('enabled')
|
||||
expect(result.current.getTriggerStatus('node-2')).toBe('disabled')
|
||||
expect(result.current.getTriggerStatus('node-3')).toBe('enabled')
|
||||
})
|
||||
|
||||
it('should replace existing statuses completely', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
// Set initial statuses
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses({
|
||||
'node-1': 'enabled',
|
||||
'node-2': 'disabled',
|
||||
})
|
||||
})
|
||||
|
||||
// Replace with new statuses
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses({
|
||||
'node-3': 'enabled',
|
||||
'node-4': 'disabled',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual({
|
||||
'node-3': 'enabled',
|
||||
'node-4': 'disabled',
|
||||
})
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('disabled') // default
|
||||
expect(result.current.getTriggerStatus('node-2')).toBe('disabled') // default
|
||||
})
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
// Set some initial data
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'enabled')
|
||||
})
|
||||
|
||||
// Clear with empty object
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses({})
|
||||
})
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual({})
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTriggerStatus', () => {
|
||||
it('should return the correct status for existing nodes', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses({
|
||||
'enabled-node': 'enabled',
|
||||
'disabled-node': 'disabled',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.getTriggerStatus('enabled-node')).toBe('enabled')
|
||||
expect(result.current.getTriggerStatus('disabled-node')).toBe('disabled')
|
||||
})
|
||||
|
||||
it('should return "disabled" as default for non-existent nodes', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
expect(result.current.getTriggerStatus('non-existent')).toBe('disabled')
|
||||
expect(result.current.getTriggerStatus('')).toBe('disabled')
|
||||
expect(result.current.getTriggerStatus('undefined-node')).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearTriggerStatuses', () => {
|
||||
it('should clear all trigger statuses', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
// Set some statuses
|
||||
act(() => {
|
||||
result.current.setTriggerStatuses({
|
||||
'node-1': 'enabled',
|
||||
'node-2': 'disabled',
|
||||
'node-3': 'enabled',
|
||||
})
|
||||
})
|
||||
|
||||
expect(Object.keys(result.current.triggerStatuses)).toHaveLength(3)
|
||||
|
||||
// Clear all
|
||||
act(() => {
|
||||
result.current.clearTriggerStatuses()
|
||||
})
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual({})
|
||||
expect(result.current.getTriggerStatus('node-1')).toBe('disabled')
|
||||
expect(result.current.getTriggerStatus('node-2')).toBe('disabled')
|
||||
expect(result.current.getTriggerStatus('node-3')).toBe('disabled')
|
||||
})
|
||||
|
||||
it('should not throw when clearing empty statuses', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.clearTriggerStatuses()
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(result.current.triggerStatuses).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store Reactivity', () => {
|
||||
it('should notify subscribers when status changes', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
const initialTriggerStatuses = result.current.triggerStatuses
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('reactive-node', 'enabled')
|
||||
})
|
||||
|
||||
// The reference should change, indicating reactivity
|
||||
expect(result.current.triggerStatuses).not.toBe(initialTriggerStatuses)
|
||||
expect(result.current.triggerStatuses['reactive-node']).toBe('enabled')
|
||||
})
|
||||
|
||||
it('should maintain immutability when updating statuses', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-1', 'enabled')
|
||||
})
|
||||
|
||||
const firstSnapshot = result.current.triggerStatuses
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('node-2', 'disabled')
|
||||
})
|
||||
|
||||
const secondSnapshot = result.current.triggerStatuses
|
||||
|
||||
// References should be different (immutable updates)
|
||||
expect(firstSnapshot).not.toBe(secondSnapshot)
|
||||
// But the first node status should remain
|
||||
expect(secondSnapshot['node-1']).toBe('enabled')
|
||||
expect(secondSnapshot['node-2']).toBe('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid consecutive updates', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('rapid-node', 'enabled')
|
||||
result.current.setTriggerStatus('rapid-node', 'disabled')
|
||||
result.current.setTriggerStatus('rapid-node', 'enabled')
|
||||
})
|
||||
|
||||
expect(result.current.getTriggerStatus('rapid-node')).toBe('enabled')
|
||||
})
|
||||
|
||||
it('should handle setting the same status multiple times', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('same-node', 'enabled')
|
||||
})
|
||||
|
||||
const firstSnapshot = result.current.triggerStatuses
|
||||
|
||||
act(() => {
|
||||
result.current.setTriggerStatus('same-node', 'enabled')
|
||||
})
|
||||
|
||||
const secondSnapshot = result.current.triggerStatuses
|
||||
|
||||
expect(result.current.getTriggerStatus('same-node')).toBe('enabled')
|
||||
// Should still create new reference (Zustand behavior)
|
||||
expect(firstSnapshot).not.toBe(secondSnapshot)
|
||||
})
|
||||
|
||||
it('should handle special node ID formats', () => {
|
||||
const { result } = renderHook(() => useTriggerStatusStore())
|
||||
|
||||
const specialNodeIds = [
|
||||
'node-with-dashes',
|
||||
'node_with_underscores',
|
||||
'nodeWithCamelCase',
|
||||
'node123',
|
||||
'node-123-abc',
|
||||
]
|
||||
|
||||
act(() => {
|
||||
specialNodeIds.forEach((nodeId, index) => {
|
||||
const status = index % 2 === 0 ? 'enabled' : 'disabled'
|
||||
result.current.setTriggerStatus(nodeId, status as EntryNodeStatus)
|
||||
})
|
||||
})
|
||||
|
||||
specialNodeIds.forEach((nodeId, index) => {
|
||||
const expectedStatus = index % 2 === 0 ? 'enabled' : 'disabled'
|
||||
expect(result.current.getTriggerStatus(nodeId)).toBe(expectedStatus)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1 +1,2 @@
|
||||
export * from './workflow'
|
||||
export * from './trigger-status'
|
||||
|
||||
42
web/app/components/workflow/store/trigger-status.ts
Normal file
42
web/app/components/workflow/store/trigger-status.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
|
||||
export type EntryNodeStatus = 'enabled' | 'disabled'
|
||||
|
||||
type TriggerStatusState = {
|
||||
// Map of nodeId to trigger status
|
||||
triggerStatuses: Record<string, EntryNodeStatus>
|
||||
|
||||
// Actions
|
||||
setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => void
|
||||
setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => void
|
||||
getTriggerStatus: (nodeId: string) => EntryNodeStatus
|
||||
clearTriggerStatuses: () => void
|
||||
}
|
||||
|
||||
export const useTriggerStatusStore = create<TriggerStatusState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
triggerStatuses: {},
|
||||
|
||||
setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => {
|
||||
set(state => ({
|
||||
triggerStatuses: {
|
||||
...state.triggerStatuses,
|
||||
[nodeId]: status,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => {
|
||||
set({ triggerStatuses: statuses })
|
||||
},
|
||||
|
||||
getTriggerStatus: (nodeId: string): EntryNodeStatus => {
|
||||
return get().triggerStatuses[nodeId] || 'disabled'
|
||||
},
|
||||
|
||||
clearTriggerStatuses: () => {
|
||||
set({ triggerStatuses: {} })
|
||||
},
|
||||
})),
|
||||
)
|
||||
@ -20,7 +20,12 @@ export const createChatVariableSlice: StateCreator<ChatVariableSliceShape> = (se
|
||||
|
||||
return ({
|
||||
showChatVariablePanel: false,
|
||||
setShowChatVariablePanel: showChatVariablePanel => set(() => ({ showChatVariablePanel })),
|
||||
setShowChatVariablePanel: showChatVariablePanel => set(() => {
|
||||
if (showChatVariablePanel)
|
||||
return { ...hideAllPanel, showChatVariablePanel: true }
|
||||
else
|
||||
return { showChatVariablePanel: false }
|
||||
}),
|
||||
showGlobalVariablePanel: false,
|
||||
setShowGlobalVariablePanel: showGlobalVariablePanel => set(() => {
|
||||
if (showGlobalVariablePanel)
|
||||
|
||||
@ -10,11 +10,24 @@ export type EnvVariableSliceShape = {
|
||||
setEnvSecrets: (envSecrets: Record<string, string>) => void
|
||||
}
|
||||
|
||||
export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = set => ({
|
||||
showEnvPanel: false,
|
||||
setShowEnvPanel: showEnvPanel => set(() => ({ showEnvPanel })),
|
||||
environmentVariables: [],
|
||||
setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })),
|
||||
envSecrets: {},
|
||||
setEnvSecrets: envSecrets => set(() => ({ envSecrets })),
|
||||
})
|
||||
export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = (set) => {
|
||||
const hideAllPanel = {
|
||||
showDebugAndPreviewPanel: false,
|
||||
showEnvPanel: false,
|
||||
showChatVariablePanel: false,
|
||||
showGlobalVariablePanel: false,
|
||||
}
|
||||
return ({
|
||||
showEnvPanel: false,
|
||||
setShowEnvPanel: showEnvPanel => set(() => {
|
||||
if (showEnvPanel)
|
||||
return { ...hideAllPanel, showEnvPanel: true }
|
||||
else
|
||||
return { showEnvPanel: false }
|
||||
}),
|
||||
environmentVariables: [],
|
||||
setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })),
|
||||
envSecrets: {},
|
||||
setEnvSecrets: envSecrets => set(() => ({ envSecrets })),
|
||||
})
|
||||
}
|
||||
|
||||
@ -48,6 +48,11 @@ export type NodeSliceShape = {
|
||||
setLoopTimes: (loopTimes: number) => void
|
||||
iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>
|
||||
setIterParallelLogMap: (iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>) => void
|
||||
pendingSingleRun?: {
|
||||
nodeId: string
|
||||
action: 'run' | 'stop'
|
||||
}
|
||||
setPendingSingleRun: (payload?: NodeSliceShape['pendingSingleRun']) => void
|
||||
}
|
||||
|
||||
export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({
|
||||
@ -73,4 +78,6 @@ export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({
|
||||
setLoopTimes: loopTimes => set(() => ({ loopTimes })),
|
||||
iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(),
|
||||
setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })),
|
||||
pendingSingleRun: undefined,
|
||||
setPendingSingleRun: payload => set(() => ({ pendingSingleRun: payload })),
|
||||
})
|
||||
|
||||
@ -1,11 +1,24 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
|
||||
export type ToolSliceShape = {
|
||||
toolPublished: boolean
|
||||
setToolPublished: (toolPublished: boolean) => void
|
||||
lastPublishedHasUserInput: boolean
|
||||
setLastPublishedHasUserInput: (hasUserInput: boolean) => void
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}
|
||||
|
||||
export const createToolSlice: StateCreator<ToolSliceShape> = set => ({
|
||||
toolPublished: false,
|
||||
setToolPublished: toolPublished => set(() => ({ toolPublished })),
|
||||
lastPublishedHasUserInput: false,
|
||||
setLastPublishedHasUserInput: hasUserInput => set(() => ({ lastPublishedHasUserInput: hasUserInput })),
|
||||
buildInTools: undefined,
|
||||
customTools: undefined,
|
||||
workflowTools: undefined,
|
||||
mcpTools: undefined,
|
||||
})
|
||||
|
||||
@ -21,6 +21,8 @@ export type WorkflowDraftSliceShape = {
|
||||
setSyncWorkflowDraftHash: (hash: string) => void
|
||||
isSyncingWorkflowDraft: boolean
|
||||
setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void
|
||||
isWorkflowDataLoaded: boolean
|
||||
setIsWorkflowDataLoaded: (loaded: boolean) => void
|
||||
}
|
||||
|
||||
export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = set => ({
|
||||
@ -33,4 +35,6 @@ export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = s
|
||||
setSyncWorkflowDraftHash: syncWorkflowDraftHash => set(() => ({ syncWorkflowDraftHash })),
|
||||
isSyncingWorkflowDraft: false,
|
||||
setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })),
|
||||
isWorkflowDataLoaded: false,
|
||||
setIsWorkflowDataLoaded: loaded => set(() => ({ isWorkflowDataLoaded: loaded })),
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type {
|
||||
Node,
|
||||
TriggerNodeType,
|
||||
WorkflowRunningData,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
@ -13,6 +14,16 @@ type PreviewRunningData = WorkflowRunningData & {
|
||||
export type WorkflowSliceShape = {
|
||||
workflowRunningData?: PreviewRunningData
|
||||
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
|
||||
isListening: boolean
|
||||
setIsListening: (listening: boolean) => void
|
||||
listeningTriggerType: TriggerNodeType | null
|
||||
setListeningTriggerType: (triggerType: TriggerNodeType | null) => void
|
||||
listeningTriggerNodeId: string | null
|
||||
setListeningTriggerNodeId: (nodeId: string | null) => void
|
||||
listeningTriggerNodeIds: string[]
|
||||
setListeningTriggerNodeIds: (nodeIds: string[]) => void
|
||||
listeningTriggerIsAll: boolean
|
||||
setListeningTriggerIsAll: (isAll: boolean) => void
|
||||
clipboardElements: Node[]
|
||||
setClipboardElements: (clipboardElements: Node[]) => void
|
||||
selection: null | { x1: number; y1: number; x2: number; y2: number }
|
||||
@ -36,6 +47,16 @@ export type WorkflowSliceShape = {
|
||||
export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
|
||||
workflowRunningData: undefined,
|
||||
setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })),
|
||||
isListening: false,
|
||||
setIsListening: listening => set(() => ({ isListening: listening })),
|
||||
listeningTriggerType: null,
|
||||
setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })),
|
||||
listeningTriggerNodeId: null,
|
||||
setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })),
|
||||
listeningTriggerNodeIds: [],
|
||||
setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })),
|
||||
listeningTriggerIsAll: false,
|
||||
setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })),
|
||||
clipboardElements: [],
|
||||
setClipboardElements: clipboardElements => set(() => ({ clipboardElements })),
|
||||
selection: null,
|
||||
|
||||
Reference in New Issue
Block a user