mirror of https://github.com/langgenius/dify.git
feat: implement trigger-plugin support with real-time status sync (#25326)
This commit is contained in:
parent
58cbd337b5
commit
e2827e475d
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './workflow'
|
||||
export * from './trigger-status'
|
||||
|
|
|
|||
|
|
@ -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: {} })
|
||||
},
|
||||
})),
|
||||
)
|
||||
Loading…
Reference in New Issue