From e2827e475d960e0696deb7ff0d0439551fb92cae Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sun, 7 Sep 2025 21:29:53 +0800 Subject: [PATCH] feat: implement trigger-plugin support with real-time status sync (#25326) --- ...ers_when_app_published_workflow_updated.py | 2 +- .../components/app/overview/trigger-card.tsx | 124 ++++--- .../__tests__/trigger-status-sync.test.tsx | 339 ++++++++++++++++++ .../_base/components/entry-node-container.tsx | 7 +- .../components/workflow/nodes/_base/node.tsx | 18 +- .../store/__tests__/trigger-status.test.ts | 293 +++++++++++++++ web/app/components/workflow/store/index.ts | 1 + .../workflow/store/trigger-status.ts | 41 +++ 8 files changed, 774 insertions(+), 51 deletions(-) create mode 100644 web/app/components/workflow/__tests__/trigger-status-sync.test.tsx create mode 100644 web/app/components/workflow/store/__tests__/trigger-status.test.ts create mode 100644 web/app/components/workflow/store/trigger-status.ts diff --git a/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py b/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py index ebd700a87a..646d6ecc87 100644 --- a/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py +++ b/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @@ -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 = [ { diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index f9ebcbed46..7e3af6fc20 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -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 } -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 ( -
- - {getStatusDot()} -
- ) + blockType = BlockEnum.TriggerWebhook + break case 'trigger-schedule': - return ( -
- - {getStatusDot()} -
- ) + blockType = BlockEnum.TriggerSchedule + break case 'trigger-plugin': - return ( -
- {icon ? ( -
- ) : ( - - )} - {getStatusDot()} -
- ) + blockType = BlockEnum.TriggerPlugin + break default: - return ( -
- - {getStatusDot()} -
- ) + 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 ( +
+ + {getStatusDot()} +
+ ) } 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) + + // 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) {
{triggers.map(trigger => (
-
- {getTriggerIcon(trigger)} -
+
+
+ {getTriggerIcon(trigger, triggerPlugins || [])} +
+
{trigger.title}
-
-
+
+
{trigger.status === 'enabled' ? t('appOverview.overview.status.running') : t('appOverview.overview.status.disable')}
- onToggleTrigger(trigger, enabled)} - disabled={!isCurrentWorkspaceEditor} - /> +
+ onToggleTrigger(trigger, enabled)} + disabled={!isCurrentWorkspaceEditor} + /> +
))}
diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx new file mode 100644 index 0000000000..de541ff922 --- /dev/null +++ b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx @@ -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 + +// 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 ( +
+ Status: {triggerStatus} +
+ ) +} + +// 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) => { + setTriggerStatuses(statuses) + } + + return ( +
+ + + +
+ ) +} + +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( + <> + + + + , + ) + + // 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( + <> + + + + + , + ) + + // 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( + <> + + + + + , + ) + + // 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( + <> + + + , + ) + + // Update status + act(() => { + getByTestId('toggle-node-1').click() // This updates node-1, not shared-node + }) + + // Add another component with the same nodeId + rerender( + <> + + + + , + ) + + // 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( + <> + + + , + ) + + // 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( + , + ) + + // 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( + <> + + + , + ) + + // 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 ( +
+ Status: {triggerStatus} +
+ ) + } + + it('should work correctly with optimized selector using useCallback', () => { + mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + + const { getByTestId } = render( + <> + + + + , + ) + + // 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
+ } + + const { getByTestId, rerender } = render() + + // 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() + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx index 84bf68e67e..4aa9920e88 100644 --- a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx +++ b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx @@ -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 = ({ 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 = ({
{showIndicator && ( -
+
)} {statusConfig.label} diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 3b0f4580ba..ad8f14b7be 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -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 = ({ const { t } = useTranslation() const nodeRef = useRef(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 = ({ 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 ? ( diff --git a/web/app/components/workflow/store/__tests__/trigger-status.test.ts b/web/app/components/workflow/store/__tests__/trigger-status.test.ts new file mode 100644 index 0000000000..c793def4ae --- /dev/null +++ b/web/app/components/workflow/store/__tests__/trigger-status.test.ts @@ -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) + }) + }) + }) +}) diff --git a/web/app/components/workflow/store/index.ts b/web/app/components/workflow/store/index.ts index 61cd5773ce..5ca06d2ec3 100644 --- a/web/app/components/workflow/store/index.ts +++ b/web/app/components/workflow/store/index.ts @@ -1 +1,2 @@ export * from './workflow' +export * from './trigger-status' diff --git a/web/app/components/workflow/store/trigger-status.ts b/web/app/components/workflow/store/trigger-status.ts new file mode 100644 index 0000000000..cfd86f1576 --- /dev/null +++ b/web/app/components/workflow/store/trigger-status.ts @@ -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 + + // Actions + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => void + setTriggerStatuses: (statuses: Record) => void + getTriggerStatus: (nodeId: string) => EntryNodeStatus + clearTriggerStatuses: () => void +} + +export const useTriggerStatusStore = create()( + subscribeWithSelector((set, get) => ({ + triggerStatuses: {}, + + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => { + set(state => ({ + triggerStatuses: { + ...state.triggerStatuses, + [nodeId]: status, + }, + })) + }, + + setTriggerStatuses: (statuses: Record) => { + set({ triggerStatuses: statuses }) + }, + + getTriggerStatus: (nodeId: string): EntryNodeStatus => { + return get().triggerStatuses[nodeId] || 'disabled' + }, + + clearTriggerStatuses: () => { + set({ triggerStatuses: {} }) + }, + })), +)