feat: implement trigger-plugin support with real-time status sync (#25326)

This commit is contained in:
lyzno1 2025-09-07 21:29:53 +08:00 committed by GitHub
parent 58cbd337b5
commit e2827e475d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 774 additions and 51 deletions

View File

@ -95,7 +95,7 @@ def get_trigger_infos_from_workflow(published_workflow: Workflow) -> list[dict]:
return []
nodes = graph.get("nodes", [])
trigger_types = {NodeType.TRIGGER_WEBHOOK.value, NodeType.TRIGGER_SCHEDULE.value}
trigger_types = {NodeType.TRIGGER_WEBHOOK.value, NodeType.TRIGGER_SCHEDULE.value, NodeType.TRIGGER_PLUGIN.value}
trigger_infos = [
{

View File

@ -2,7 +2,7 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { Schedule, TriggerAll, WebhookLine } from '@/app/components/base/icons/src/vender/workflow'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import Switch from '@/app/components/base/switch'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
@ -13,13 +13,18 @@ import {
useInvalidateAppTriggers,
useUpdateTriggerStatus,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
export type ITriggerCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
}
const getTriggerIcon = (trigger: AppTrigger) => {
const { trigger_type, icon, status } = trigger
const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: any[]) => {
const { trigger_type, status, provider_name } = trigger
// Status dot styling based on trigger status
const getStatusDot = () => {
@ -35,45 +40,43 @@ const getTriggerIcon = (trigger: AppTrigger) => {
}
}
const baseIconClasses = 'relative flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-white/2 shadow-xs'
// Get BlockEnum type from trigger_type
let blockType: BlockEnum
switch (trigger_type) {
case 'trigger-webhook':
return (
<div className={`${baseIconClasses} bg-util-colors-blue-blue-500 text-white`}>
<WebhookLine className="h-4 w-4" />
{getStatusDot()}
</div>
)
blockType = BlockEnum.TriggerWebhook
break
case 'trigger-schedule':
return (
<div className={`${baseIconClasses} bg-util-colors-violet-violet-500 text-white`}>
<Schedule className="h-4 w-4" />
{getStatusDot()}
</div>
)
blockType = BlockEnum.TriggerSchedule
break
case 'trigger-plugin':
return (
<div className={`${baseIconClasses} bg-util-colors-white-white-500`}>
{icon ? (
<div
className="h-full w-full shrink-0 rounded-md bg-cover bg-center"
style={{ backgroundImage: `url(${icon})` }}
/>
) : (
<WebhookLine className="h-4 w-4 text-text-secondary" />
)}
{getStatusDot()}
</div>
)
blockType = BlockEnum.TriggerPlugin
break
default:
return (
<div className={`${baseIconClasses} bg-util-colors-blue-blue-500 text-white`}>
<WebhookLine className="h-4 w-4" />
{getStatusDot()}
</div>
)
blockType = BlockEnum.TriggerWebhook
}
let toolIcon: string | undefined
if (trigger_type === 'trigger-plugin' && provider_name) {
const targetTools = triggerPlugins || []
const foundTool = targetTools.find(toolWithProvider =>
canFindTool(toolWithProvider.id, provider_name)
|| toolWithProvider.id.includes(provider_name)
|| toolWithProvider.name === provider_name,
)
toolIcon = foundTool?.icon
}
return (
<div className="relative">
<BlockIcon
type={blockType}
size="md"
toolIcon={toolIcon}
/>
{getStatusDot()}
</div>
)
}
function TriggerCard({ appInfo }: ITriggerCardProps) {
@ -83,12 +86,34 @@ function TriggerCard({ appInfo }: ITriggerCardProps) {
const { data: triggersResponse, isLoading } = useAppTriggers(appId)
const { mutateAsync: updateTriggerStatus } = useUpdateTriggerStatus()
const invalidateAppTriggers = useInvalidateAppTriggers()
const { data: triggerPlugins } = useAllTriggerPlugins()
// Zustand store for trigger status sync
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const triggers = triggersResponse?.data || []
const triggerCount = triggers.length
// Sync trigger statuses to Zustand store when data loads initially or after API calls
React.useEffect(() => {
if (triggers.length > 0) {
const statusMap = triggers.reduce((acc, trigger) => {
// Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {} as Record<string, 'enabled' | 'disabled'>)
// Only update if there are actual changes to prevent overriding optimistic updates
setTriggerStatuses(statusMap)
}
}, [triggers, setTriggerStatuses])
const onToggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
try {
// Immediately update Zustand store for real-time UI sync
const newStatus = enabled ? 'enabled' : 'disabled'
setTriggerStatus(trigger.node_id, newStatus)
await updateTriggerStatus({
appId,
triggerId: trigger.id,
@ -97,6 +122,9 @@ function TriggerCard({ appInfo }: ITriggerCardProps) {
invalidateAppTriggers(appId)
}
catch (error) {
// Rollback Zustand store state on error
const rollbackStatus = enabled ? 'disabled' : 'enabled'
setTriggerStatus(trigger.node_id, rollbackStatus)
console.error('Failed to update trigger status:', error)
}
}
@ -142,24 +170,28 @@ function TriggerCard({ appInfo }: ITriggerCardProps) {
<div className="flex flex-col gap-2 p-3">
{triggers.map(trigger => (
<div key={trigger.id} className="flex w-full items-center gap-3">
<div className="flex grow items-center gap-2">
{getTriggerIcon(trigger)}
<div className="system-sm-medium text-text-secondary">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="shrink-0">
{getTriggerIcon(trigger, triggerPlugins || [])}
</div>
<div className="system-sm-medium min-w-0 flex-1 truncate text-text-secondary">
{trigger.title}
</div>
</div>
<div className="flex items-center">
<div className={`${trigger.status === 'enabled' ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
<div className="flex shrink-0 items-center">
<div className={`${trigger.status === 'enabled' ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase whitespace-nowrap`}>
{trigger.status === 'enabled'
? t('appOverview.overview.status.running')
: t('appOverview.overview.status.disable')}
</div>
</div>
<Switch
defaultValue={trigger.status === 'enabled'}
onChange={enabled => onToggleTrigger(trigger, enabled)}
disabled={!isCurrentWorkspaceEditor}
/>
<div className="shrink-0">
<Switch
defaultValue={trigger.status === 'enabled'}
onChange={enabled => onToggleTrigger(trigger, enabled)}
disabled={!isCurrentWorkspaceEditor}
/>
</div>
</div>
))}
</div>

View File

@ -0,0 +1,339 @@
import React, { useCallback } from 'react'
import { act, render } from '@testing-library/react'
import { useTriggerStatusStore } from '../store/trigger-status'
import { isTriggerNode } from '../types'
import type { EntryNodeStatus } from '../nodes/_base/components/entry-node-container'
// Mock the isTriggerNode function
jest.mock('../types', () => ({
isTriggerNode: jest.fn(),
}))
const mockIsTriggerNode = isTriggerNode as jest.MockedFunction<typeof isTriggerNode>
// Test component that mimics BaseNode's usage pattern
const TestTriggerNode: React.FC<{
nodeId: string
nodeType: string
}> = ({ nodeId, nodeType }) => {
const triggerStatus = useTriggerStatusStore(state =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
)
return (
<div data-testid={`node-${nodeId}`} data-status={triggerStatus}>
Status: {triggerStatus}
</div>
)
}
// Test component that mimics TriggerCard's usage pattern
const TestTriggerController: React.FC = () => {
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const handleToggle = (nodeId: string, enabled: boolean) => {
const newStatus = enabled ? 'enabled' : 'disabled'
setTriggerStatus(nodeId, newStatus)
}
const handleBatchUpdate = (statuses: Record<string, EntryNodeStatus>) => {
setTriggerStatuses(statuses)
}
return (
<div>
<button
data-testid="toggle-node-1"
onClick={() => handleToggle('node-1', true)}
>
Enable Node 1
</button>
<button
data-testid="toggle-node-2"
onClick={() => handleToggle('node-2', false)}
>
Disable Node 2
</button>
<button
data-testid="batch-update"
onClick={() => handleBatchUpdate({
'node-1': 'disabled',
'node-2': 'enabled',
'node-3': 'enabled',
})}
>
Batch Update
</button>
</div>
)
}
describe('Trigger Status Synchronization Integration', () => {
beforeEach(() => {
// Clear store state
act(() => {
const store = useTriggerStatusStore.getState()
store.clearTriggerStatuses()
})
// Reset mocks
jest.clearAllMocks()
})
describe('Real-time Status Synchronization', () => {
it('should sync status changes between trigger controller and nodes', () => {
mockIsTriggerNode.mockReturnValue(true)
const { getByTestId } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
<TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" />
</>,
)
// Initial state - should be 'disabled' by default
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
// Enable node-1
act(() => {
getByTestId('toggle-node-1').click()
})
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
// Disable node-2 (should remain disabled)
act(() => {
getByTestId('toggle-node-2').click()
})
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
})
it('should handle batch status updates correctly', () => {
mockIsTriggerNode.mockReturnValue(true)
const { getByTestId } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
<TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" />
<TestTriggerNode nodeId="node-3" nodeType="trigger-plugin" />
</>,
)
// Initial state
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'disabled')
// Batch update
act(() => {
getByTestId('batch-update').click()
})
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled')
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled')
})
it('should handle mixed node types (trigger vs non-trigger)', () => {
// Mock different node types
mockIsTriggerNode.mockImplementation((nodeType: string) => {
return nodeType.startsWith('trigger-')
})
const { getByTestId } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
<TestTriggerNode nodeId="node-2" nodeType="start" />
<TestTriggerNode nodeId="node-3" nodeType="llm" />
</>,
)
// Trigger node should use store status, non-trigger nodes should be 'enabled'
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') // trigger node
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // start node
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // llm node
// Update trigger node status
act(() => {
getByTestId('toggle-node-1').click()
})
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') // updated
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // unchanged
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // unchanged
})
})
describe('Store State Management', () => {
it('should maintain state consistency across multiple components', () => {
mockIsTriggerNode.mockReturnValue(true)
// Render multiple instances of the same node
const { getByTestId, rerender } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
</>,
)
// Update status
act(() => {
getByTestId('toggle-node-1').click() // This updates node-1, not shared-node
})
// Add another component with the same nodeId
rerender(
<>
<TestTriggerController />
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
</>,
)
// Both components should show the same status
const nodes = document.querySelectorAll('[data-testid="node-shared-node"]')
expect(nodes).toHaveLength(2)
nodes.forEach((node) => {
expect(node).toHaveAttribute('data-status', 'disabled')
})
})
it('should handle rapid status changes correctly', () => {
mockIsTriggerNode.mockReturnValue(true)
const { getByTestId } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
</>,
)
// Rapid consecutive updates
act(() => {
// Multiple rapid clicks
getByTestId('toggle-node-1').click() // enable
getByTestId('toggle-node-2').click() // disable (different node)
getByTestId('toggle-node-1').click() // enable again
})
// Should reflect the final state
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
})
})
describe('Error Scenarios', () => {
it('should handle non-existent node IDs gracefully', () => {
mockIsTriggerNode.mockReturnValue(true)
const { getByTestId } = render(
<TestTriggerNode nodeId="non-existent-node" nodeType="trigger-webhook" />,
)
// Should default to 'disabled' for non-existent nodes
expect(getByTestId('node-non-existent-node')).toHaveAttribute('data-status', 'disabled')
})
it('should handle component unmounting gracefully', () => {
mockIsTriggerNode.mockReturnValue(true)
const { getByTestId, unmount } = render(
<>
<TestTriggerController />
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
</>,
)
// Update status
act(() => {
getByTestId('toggle-node-1').click()
})
// Unmount components
expect(() => unmount()).not.toThrow()
// Store should still maintain the state
const store = useTriggerStatusStore.getState()
expect(store.triggerStatuses['node-1']).toBe('enabled')
})
})
describe('Performance Optimization', () => {
// Component that uses optimized selector with useCallback
const OptimizedTriggerNode: React.FC<{
nodeId: string
nodeType: string
}> = ({ nodeId, nodeType }) => {
const triggerStatusSelector = useCallback((state: any) =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
[nodeId, nodeType],
)
const triggerStatus = useTriggerStatusStore(triggerStatusSelector)
return (
<div data-testid={`optimized-node-${nodeId}`} data-status={triggerStatus}>
Status: {triggerStatus}
</div>
)
}
it('should work correctly with optimized selector using useCallback', () => {
mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook')
const { getByTestId } = render(
<>
<OptimizedTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
<OptimizedTriggerNode nodeId="node-2" nodeType="start" />
<TestTriggerController />
</>,
)
// Initial state
expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'disabled')
expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled')
// Update status via controller
act(() => {
getByTestId('toggle-node-1').click()
})
// Verify optimized component updates correctly
expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'enabled')
expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled')
})
it('should handle selector dependency changes correctly', () => {
mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook')
const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => {
const triggerStatusSelector = useCallback((state: any) =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
['test-node', nodeType], // Dependencies should match implementation
)
const status = useTriggerStatusStore(triggerStatusSelector)
return <div data-testid="test-component" data-status={status} />
}
const { getByTestId, rerender } = render(<TestComponent nodeType="trigger-webhook" />)
// Initial trigger node
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'disabled')
// Set status for the node
act(() => {
useTriggerStatusStore.getState().setTriggerStatus('test-node', 'enabled')
})
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled')
// Change node type to non-trigger - should return 'enabled' regardless of store
rerender(<TestComponent nodeType="start" />)
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled')
})
})
})

View File

@ -1,7 +1,6 @@
import type { FC, ReactNode } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
export type EntryNodeStatus = 'enabled' | 'disabled'
@ -28,7 +27,9 @@ const EntryNodeContainer: FC<EntryNodeContainerProps> = ({
return {
label: customLabel || (isDisabled ? t(`workflow.${translationKey}.disabled`) : t(`workflow.${translationKey}.enabled`)),
dotColor: isDisabled ? 'bg-text-tertiary' : 'bg-green-500',
dotClasses: isDisabled
? 'bg-components-badge-status-light-disabled-bg border-components-badge-status-light-disabled-border-inner'
: 'bg-components-badge-status-light-success-bg border-components-badge-status-light-success-border-inner',
}
}, [status, customLabel, nodeType, t])
@ -36,7 +37,7 @@ const EntryNodeContainer: FC<EntryNodeContainerProps> = ({
<div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pb-0 pt-0.5">
<div className="mb-0.5 flex items-center px-1.5 pt-0.5">
{showIndicator && (
<div className={cn('ml-0.5 mr-0.5 h-1.5 w-1.5 rounded-sm border border-black/15', statusConfig.dotColor)} />
<div className={`ml-0.5 mr-0.5 h-1.5 w-1.5 rounded-sm border ${statusConfig.dotClasses}`} />
)}
<span className={`text-2xs font-semibold uppercase text-text-tertiary ${!showIndicator ? 'ml-1.5' : ''}`}>
{statusConfig.label}

View File

@ -5,6 +5,7 @@ import type {
import {
cloneElement,
memo,
useCallback,
useEffect,
useMemo,
useRef,
@ -49,6 +50,8 @@ import BlockIcon from '@/app/components/workflow/block-icon'
import Tooltip from '@/app/components/base/tooltip'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
import { ToolTypeEnum } from '../../block-selector/types'
import { useTriggerStatusStore } from '../../store/trigger-status'
import { isTriggerNode } from '../../types'
type BaseNodeProps = {
children: ReactElement
@ -64,6 +67,14 @@ const BaseNode: FC<BaseNodeProps> = ({
const { t } = useTranslation()
const nodeRef = useRef<HTMLDivElement>(null)
const { nodesReadOnly } = useNodesReadOnly()
// Subscribe to trigger status for this specific node ID (reactive)
// Use useCallback to optimize selector and prevent unnecessary re-renders
const triggerStatusSelector = useCallback((state: any) =>
isTriggerNode(data.type) ? (state.triggerStatuses[id] || 'disabled') : 'enabled',
[id, data.type],
)
const triggerStatus = useTriggerStatusStore(triggerStatusSelector)
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
@ -338,9 +349,14 @@ const BaseNode: FC<BaseNodeProps> = ({
const isEntryNode = TRIGGER_NODE_TYPES.includes(data.type as any) || data.type === BlockEnum.Start
const isStartNode = data.type === BlockEnum.Start
// Determine node status dynamically
const nodeStatus = isStartNode
? 'enabled' // Start nodes are always enabled (green)
: triggerStatus // Use reactive trigger status
return isEntryNode ? (
<EntryNodeContainer
status="enabled"
status={nodeStatus}
showIndicator={!isStartNode}
nodeType={isStartNode ? 'start' : 'trigger'}
>

View File

@ -0,0 +1,293 @@
import { act, renderHook } from '@testing-library/react'
import { useTriggerStatusStore } from '../trigger-status'
import type { EntryNodeStatus } from '../../nodes/_base/components/entry-node-container'
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)
})
})
})
})

View File

@ -1 +1,2 @@
export * from './workflow'
export * from './trigger-status'

View File

@ -0,0 +1,41 @@
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import type { EntryNodeStatus } from '../nodes/_base/components/entry-node-container'
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: {} })
},
})),
)