From a408a5d87e915250eaafc58c0861411e4163c573 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 24 Mar 2026 17:51:07 +0800 Subject: [PATCH] test(workflow): add helper specs and raise targeted workflow coverage (#33995) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../block-selector/__tests__/tabs.spec.tsx | 276 ++++++++++++ .../workflow/block-selector/tabs.tsx | 229 ++++++---- .../__tests__/test-run-menu-helpers.spec.tsx | 128 ++++++ .../header/__tests__/test-run-menu.spec.tsx | 125 ++++++ .../workflow/header/test-run-menu-helpers.tsx | 118 +++++ .../workflow/header/test-run-menu.tsx | 137 ++---- .../__tests__/use-edges-interactions.spec.ts | 73 +++ .../hooks/__tests__/use-helpline.spec.ts | 56 +++ .../hooks/__tests__/use-tool-icon.spec.ts | 83 +++- .../use-workflow-interactions.spec.tsx | 329 ++++++++++++++ .../use-workflow-organize.helpers.spec.ts | 123 +++++ .../hooks/use-edges-interactions.helpers.ts | 77 ++++ .../workflow/hooks/use-edges-interactions.ts | 130 ++---- .../components/workflow/hooks/use-helpline.ts | 278 +++++++----- .../workflow/hooks/use-tool-icon.ts | 275 ++++++------ .../hooks/use-workflow-canvas-maximize.ts | 28 ++ .../hooks/use-workflow-interactions.ts | 360 +-------------- .../hooks/use-workflow-organize.helpers.ts | 138 ++++++ .../workflow/hooks/use-workflow-organize.ts | 71 +++ .../hooks/use-workflow-panel-interactions.ts | 52 +++ .../workflow/hooks/use-workflow-update.ts | 37 ++ .../workflow/hooks/use-workflow-zoom.ts | 31 ++ .../__tests__/use-config-test-utils.spec.ts | 24 + .../nodes/__tests__/use-config-test-utils.ts | 13 + .../__tests__/use-config.helpers.spec.ts | 68 +++ .../assigner/__tests__/use-config.spec.tsx | 98 ++++ .../nodes/assigner/use-config.helpers.ts | 90 ++++ .../workflow/nodes/assigner/use-config.ts | 70 +-- .../components/__tests__/curl-panel.spec.tsx | 165 +++++++ .../nodes/http/components/curl-panel.tsx | 100 +---- .../nodes/http/components/curl-parser.ts | 171 +++++++ .../__tests__/variable-in-markdown.spec.tsx | 114 +++++ .../components/variable-in-markdown.tsx | 212 ++++----- .../__tests__/use-config.helpers.spec.ts | 172 +++++++ .../if-else/__tests__/use-config.spec.tsx | 266 +++++++++++ .../nodes/if-else/use-config.helpers.ts | 237 ++++++++++ .../workflow/nodes/if-else/use-config.ts | 230 +++------- .../use-interactions.helpers.spec.ts | 111 +++++ .../__tests__/use-interactions.spec.tsx | 181 ++++++++ .../iteration/use-interactions.helpers.ts | 113 +++++ .../nodes/iteration/use-interactions.ts | 106 ++--- .../__tests__/use-config.helpers.spec.ts | 108 +++++ .../__tests__/use-config.spec.tsx | 183 ++++++++ .../__tests__/filter-condition.spec.tsx | 310 +++++++++++++ .../components/filter-condition.tsx | 244 ++++++---- .../nodes/list-operator/use-config.helpers.ts | 150 +++++++ .../nodes/list-operator/use-config.ts | 129 ++---- .../loop/__tests__/use-config.helpers.spec.ts | 216 +++++++++ .../nodes/loop/__tests__/use-config.spec.tsx | 221 +++++++++ .../use-interactions.helpers.spec.ts | 100 +++++ .../loop/__tests__/use-interactions.spec.tsx | 174 +++++++ ...use-single-run-form-params.helpers.spec.ts | 241 ++++++++++ .../use-single-run-form-params.spec.ts | 216 +++++++++ .../workflow/nodes/loop/use-config.helpers.ts | 171 +++++++ .../workflow/nodes/loop/use-config.ts | 161 ++----- .../nodes/loop/use-interactions.helpers.ts | 109 +++++ .../workflow/nodes/loop/use-interactions.ts | 103 ++--- .../use-single-run-form-params.helpers.ts | 131 ++++++ .../nodes/loop/use-single-run-form-params.ts | 157 ++----- .../__tests__/use-config.helpers.spec.ts | 196 ++++++++ .../__tests__/use-config.spec.tsx | 207 +++++++++ .../__tests__/generic-table.spec.tsx | 197 ++++++++ .../components/generic-table.tsx | 220 +++++---- .../trigger-webhook/use-config.helpers.ts | 220 +++++++++ .../nodes/trigger-webhook/use-config.ts | 232 +++------- .../__tests__/use-config.spec.tsx | 255 +++++++++++ .../variable-assigner/use-config.helpers.ts | 99 ++++ .../nodes/variable-assigner/use-config.ts | 138 ++---- .../toolbar/__tests__/hooks.spec.tsx | 209 +++++++++ .../note-node/note-editor/toolbar/hooks.ts | 114 ++--- .../panel/env-panel/__tests__/index.spec.tsx | 424 ++++++++++++++++++ .../workflow/panel/env-panel/index.tsx | 200 +++++---- .../__tests__/iteration-log-trigger.spec.tsx | 189 ++++++++ .../iteration-log/iteration-log-trigger.tsx | 171 +++---- web/eslint-suppressions.json | 19 +- 75 files changed, 9402 insertions(+), 2507 deletions(-) create mode 100644 web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx create mode 100644 web/app/components/workflow/header/test-run-menu-helpers.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts create mode 100644 web/app/components/workflow/hooks/use-edges-interactions.helpers.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-organize.helpers.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-organize.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-panel-interactions.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-update.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-zoom.ts create mode 100644 web/app/components/workflow/nodes/__tests__/use-config-test-utils.spec.ts create mode 100644 web/app/components/workflow/nodes/__tests__/use-config-test-utils.ts create mode 100644 web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/assigner/use-config.helpers.ts create mode 100644 web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx create mode 100644 web/app/components/workflow/nodes/http/components/curl-parser.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx create mode 100644 web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/if-else/use-config.helpers.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx create mode 100644 web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts create mode 100644 web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx create mode 100644 web/app/components/workflow/nodes/list-operator/use-config.helpers.ts create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/loop/use-config.helpers.ts create mode 100644 web/app/components/workflow/nodes/loop/use-interactions.helpers.ts create mode 100644 web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts create mode 100644 web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts create mode 100644 web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx create mode 100644 web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts create mode 100644 web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx create mode 100644 web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx new file mode 100644 index 0000000000..7e73e1debd --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import Tabs from '../tabs' +import { TabsEnum } from '../types' + +const { + mockSetState, + mockInvalidateBuiltInTools, + mockToolsState, +} = vi.hoisted(() => ({ + mockSetState: vi.fn(), + mockInvalidateBuiltInTools: vi.fn(), + mockToolsState: { + buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as Array<{ icon: string | Record, name: string }> | undefined, + customTools: [] as Array<{ icon: string | Record, name: string }> | undefined, + workflowTools: [] as Array<{ icon: string | Record, name: string }> | undefined, + mcpTools: [] as Array<{ icon: string | Record, name: string }> | undefined, + }, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + }: { + children: React.ReactNode + popupContent: React.ReactNode + }) => ( +
+ {popupContent} + {children} +
+ ), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({ + systemFeatures: { enable_marketplace: true }, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFeaturedToolsRecommendations: () => ({ + plugins: [], + isLoading: false, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: mockToolsState.buildInTools }), + useAllCustomTools: () => ({ data: mockToolsState.customTools }), + useAllWorkflowTools: () => ({ data: mockToolsState.workflowTools }), + useAllMCPTools: () => ({ data: mockToolsState.mcpTools }), + useInvalidateAllBuiltInTools: () => mockInvalidateBuiltInTools, +})) + +vi.mock('@/utils/var', () => ({ + basePath: '/console', +})) + +vi.mock('../../store', () => ({ + useWorkflowStore: () => ({ + setState: mockSetState, + }), +})) + +vi.mock('../all-start-blocks', () => ({ + default: () =>
start-content
, +})) + +vi.mock('../blocks', () => ({ + default: () =>
blocks-content
, +})) + +vi.mock('../data-sources', () => ({ + default: () =>
sources-content
, +})) + +vi.mock('../all-tools', () => ({ + default: (props: { + buildInTools: Array<{ icon: string | Record }> + showFeatured: boolean + featuredLoading: boolean + onFeaturedInstallSuccess: () => Promise + }) => ( +
+ tools-content + {props.buildInTools.map((tool, index) => ( + + {typeof tool.icon === 'string' ? tool.icon : 'object-icon'} + + ))} + {props.showFeatured ? 'featured-on' : 'featured-off'} + {props.featuredLoading ? 'featured-loading' : 'featured-idle'} + +
+ ), +})) + +describe('Tabs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockToolsState.buildInTools = [{ icon: '/tool.svg', name: 'tool' }] + mockToolsState.customTools = [] + mockToolsState.workflowTools = [] + mockToolsState.mcpTools = [] + }) + + const baseProps = { + activeTab: TabsEnum.Start, + onActiveTabChange: vi.fn(), + searchText: '', + tags: [], + onTagsChange: vi.fn(), + onSelect: vi.fn(), + blocks: [], + tabs: [ + { key: TabsEnum.Start, name: 'Start' }, + { key: TabsEnum.Blocks, name: 'Blocks', disabled: true }, + { key: TabsEnum.Tools, name: 'Tools' }, + ], + filterElem:
filter
, + } + + it('should render start content and disabled tab tooltip text', () => { + render() + + expect(screen.getByText('start-content')).toBeInTheDocument() + expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument() + }) + + it('should switch tabs through click handlers and render tools content with normalized icons', () => { + const onActiveTabChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('Start')) + + expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start) + expect(screen.getByText('tools-content')).toBeInTheDocument() + expect(screen.getByText('/console/tool.svg')).toBeInTheDocument() + expect(screen.getByText('featured-on')).toBeInTheDocument() + expect(screen.getByText('featured-idle')).toBeInTheDocument() + }) + + it('should sync normalized tools into workflow store state', () => { + render() + + expect(mockSetState).toHaveBeenCalled() + }) + + it('should ignore clicks on disabled and already active tabs', async () => { + const user = userEvent.setup() + const onActiveTabChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('Start')) + await user.click(screen.getByText('Blocks')) + + expect(onActiveTabChange).not.toHaveBeenCalled() + }) + + it('should render sources content when the sources tab is active and data sources are provided', () => { + render( + , + ) + + expect(screen.getByText('sources-content')).toBeInTheDocument() + }) + + it('should keep the previous workflow store state when tool references do not change', () => { + mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }] + + render() + + const previousState = { + buildInTools: mockToolsState.buildInTools, + customTools: mockToolsState.customTools, + workflowTools: mockToolsState.workflowTools, + mcpTools: mockToolsState.mcpTools, + } + const updateState = mockSetState.mock.calls[0][0] as (state: typeof previousState) => typeof previousState + + expect(updateState(previousState)).toBe(previousState) + }) + + it('should normalize every tool collection and merge updates into workflow store state', () => { + mockToolsState.buildInTools = [{ icon: { light: '/tool.svg' }, name: 'tool' }] + mockToolsState.customTools = [{ icon: '/custom.svg', name: 'custom' }] + mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }] + mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }] + + render() + + expect(screen.getByText('object-icon')).toBeInTheDocument() + + const updateState = mockSetState.mock.calls[0][0] as (state: { + buildInTools?: Array<{ icon: string | Record, name: string }> + customTools?: Array<{ icon: string | Record, name: string }> + workflowTools?: Array<{ icon: string | Record, name: string }> + mcpTools?: Array<{ icon: string | Record, name: string }> + }) => { + buildInTools?: Array<{ icon: string | Record, name: string }> + customTools?: Array<{ icon: string | Record, name: string }> + workflowTools?: Array<{ icon: string | Record, name: string }> + mcpTools?: Array<{ icon: string | Record, name: string }> + } + + expect(updateState({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + })).toEqual({ + buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }], + customTools: [{ icon: '/console/custom.svg', name: 'custom' }], + workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }], + mcpTools: [{ icon: '/console/mcp.svg', name: 'mcp' }], + }) + }) + + it('should skip normalization when a tool list is undefined', () => { + mockToolsState.buildInTools = undefined + + render() + + expect(screen.getByText('tools-content')).toBeInTheDocument() + }) + + it('should force start content to render and invalidate built-in tools after featured installs', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Install featured tool' })) + + expect(screen.getByText('tools-content')).toBeInTheDocument() + expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) + }) + + it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => { + render( + , + ) + + expect(screen.getByText('start-content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index f1eeba7435..4f7df70c19 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -41,6 +41,122 @@ export type TabsProps = { forceShowStartContent?: boolean // Force show Start content even when noBlocks=true allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet). } + +const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => { + if (!list || !currentBasePath) + return list + + let changed = false + const normalized = list.map((provider) => { + if (typeof provider.icon !== 'string') + return provider + + const shouldPrefix = provider.icon.startsWith('/') + && !provider.icon.startsWith(`${currentBasePath}/`) + + if (!shouldPrefix) + return provider + + changed = true + return { + ...provider, + icon: `${currentBasePath}${provider.icon}`, + } + }) + + return changed ? normalized : list +} + +const getStoreToolUpdates = ({ + state, + buildInTools, + customTools, + workflowTools, + mcpTools, +}: { + state: { + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] + } + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] +}) => { + const updates: Partial = {} + + if (buildInTools !== undefined && state.buildInTools !== buildInTools) + updates.buildInTools = buildInTools + if (customTools !== undefined && state.customTools !== customTools) + updates.customTools = customTools + if (workflowTools !== undefined && state.workflowTools !== workflowTools) + updates.workflowTools = workflowTools + if (mcpTools !== undefined && state.mcpTools !== mcpTools) + updates.mcpTools = mcpTools + + return updates +} + +const TabHeaderItem = ({ + tab, + activeTab, + onActiveTabChange, + disabledTip, +}: { + tab: TabsProps['tabs'][number] + activeTab: TabsEnum + onActiveTabChange: (activeTab: TabsEnum) => void + disabledTip: string +}) => { + const className = cn( + 'relative mr-0.5 flex h-8 items-center rounded-t-lg px-3 system-sm-medium', + tab.disabled + ? 'cursor-not-allowed text-text-disabled opacity-60' + : activeTab === tab.key + // eslint-disable-next-line tailwindcss/no-unknown-classes + ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' + : 'cursor-pointer text-text-tertiary', + ) + + const handleClick = () => { + if (tab.disabled || activeTab === tab.key) + return + onActiveTabChange(tab.key) + } + + if (tab.disabled) { + return ( + +
+ {tab.name} +
+
+ ) + } + + return ( +
+ {tab.name} +
+ ) +} + const Tabs: FC = ({ activeTab, onActiveTabChange, @@ -71,51 +187,21 @@ const Tabs: FC = ({ plugins: featuredPlugins = [], isLoading: isFeaturedLoading, } = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) - - const normalizeToolList = useMemo(() => { - return (list?: ToolWithProvider[]) => { - if (!list) - return list - if (!basePath) - return list - let changed = false - const normalized = list.map((provider) => { - if (typeof provider.icon === 'string') { - const icon = provider.icon - const shouldPrefix = Boolean(basePath) - && icon.startsWith('/') - && !icon.startsWith(`${basePath}/`) - - if (shouldPrefix) { - changed = true - return { - ...provider, - icon: `${basePath}${icon}`, - } - } - } - return provider - }) - return changed ? normalized : list - } - }, [basePath]) + const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools]) + const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools]) + const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools]) + const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools]) + const disabledTip = t('tabs.startDisabledTip', { ns: 'workflow' }) useEffect(() => { workflowStore.setState((state) => { - const updates: Partial = {} - const normalizedBuiltIn = normalizeToolList(buildInTools) - const normalizedCustom = normalizeToolList(customTools) - const normalizedWorkflow = normalizeToolList(workflowTools) - const normalizedMCP = normalizeToolList(mcpTools) - - if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn) - updates.buildInTools = normalizedBuiltIn - if (normalizedCustom !== undefined && state.customTools !== normalizedCustom) - updates.customTools = normalizedCustom - if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow) - updates.workflowTools = normalizedWorkflow - if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP) - updates.mcpTools = normalizedMCP + const updates = getStoreToolUpdates({ + state, + buildInTools: normalizedBuiltInTools, + customTools: normalizedCustomTools, + workflowTools: normalizedWorkflowTools, + mcpTools: normalizedMcpTools, + }) if (!Object.keys(updates).length) return state return { @@ -123,7 +209,7 @@ const Tabs: FC = ({ ...updates, } }) - }, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools]) + }, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore]) return (
e.stopPropagation()}> @@ -131,46 +217,15 @@ const Tabs: FC = ({ !noBlocks && (
{ - tabs.map((tab) => { - const commonProps = { - 'className': cn( - 'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3', - tab.disabled - ? 'cursor-not-allowed text-text-disabled opacity-60' - : activeTab === tab.key - ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' - : 'cursor-pointer text-text-tertiary', - ), - 'aria-disabled': tab.disabled, - 'onClick': () => { - if (tab.disabled || activeTab === tab.key) - return - onActiveTabChange(tab.key) - }, - } as const - if (tab.disabled) { - return ( - -
- {tab.name} -
-
- ) - } - return ( -
- {tab.name} -
- ) - }) + tabs.map(tab => ( + + )) }
) @@ -219,10 +274,10 @@ const Tabs: FC = ({ onSelect={onSelect} tags={tags} canNotSelectMultiple - buildInTools={buildInTools || []} - customTools={customTools || []} - workflowTools={workflowTools || []} - mcpTools={mcpTools || []} + buildInTools={normalizedBuiltInTools || []} + customTools={normalizedCustomTools || []} + workflowTools={normalizedWorkflowTools || []} + mcpTools={normalizedMcpTools || []} onTagsChange={onTagsChange} isInRAGPipeline={inRAGPipeline} featuredPlugins={featuredPlugins} diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx new file mode 100644 index 0000000000..cce4c070a1 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -0,0 +1,128 @@ +import type { TriggerOption } from '../test-run-menu' +import { fireEvent, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { TriggerType } from '../test-run-menu' +import { + getNormalizedShortcutKey, + OptionRow, + SingleOptionTrigger, + useShortcutMenu, +} from '../test-run-menu-helpers' + +vi.mock('../shortcuts-name', () => ({ + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +const createOption = (overrides: Partial = {}): TriggerOption => ({ + id: 'user-input', + type: TriggerType.UserInput, + name: 'User Input', + icon: icon, + enabled: true, + ...overrides, +}) + +describe('test-run-menu helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should normalize shortcut keys and render option rows with clickable shortcuts', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const option = createOption() + + expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~') + expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1') + + render( + , + ) + + expect(screen.getByText('1')).toBeInTheDocument() + + await user.click(screen.getByText('User Input')) + + expect(onSelect).toHaveBeenCalledWith(option) + }) + + it('should handle shortcut key presses only when the menu is open and the event is eligible', () => { + const handleSelect = vi.fn() + const option = createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }) + + const { rerender, unmount } = renderHook(({ open }) => useShortcutMenu({ + open, + shortcutMappings: [{ option, shortcutKey: '~' }], + handleSelect, + }), { + initialProps: { open: true }, + }) + + fireEvent.keyDown(window, { key: '`' }) + fireEvent.keyDown(window, { key: '`', altKey: true }) + fireEvent.keyDown(window, { key: '`', repeat: true }) + + const preventedEvent = new KeyboardEvent('keydown', { key: '`', cancelable: true }) + preventedEvent.preventDefault() + window.dispatchEvent(preventedEvent) + + expect(handleSelect).toHaveBeenCalledTimes(1) + expect(handleSelect).toHaveBeenCalledWith(option) + + rerender({ open: false }) + fireEvent.keyDown(window, { key: '`' }) + expect(handleSelect).toHaveBeenCalledTimes(1) + + unmount() + fireEvent.keyDown(window, { key: '`' }) + expect(handleSelect).toHaveBeenCalledTimes(1) + }) + + it('should run single options for element and non-element children unless the click is prevented', async () => { + const user = userEvent.setup() + const runSoleOption = vi.fn() + const originalOnClick = vi.fn() + + const { rerender } = render( + + Open directly + , + ) + + await user.click(screen.getByText('Open directly')) + expect(runSoleOption).toHaveBeenCalledTimes(1) + + rerender( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Child trigger' })) + expect(originalOnClick).toHaveBeenCalledTimes(1) + expect(runSoleOption).toHaveBeenCalledTimes(2) + + rerender( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Prevented child' })) + + expect(originalOnClick).toHaveBeenCalledTimes(2) + expect(runSoleOption).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx new file mode 100644 index 0000000000..2e3384b61e --- /dev/null +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -0,0 +1,125 @@ +import type { TestRunMenuRef, TriggerOption } from '../test-run-menu' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import * as React from 'react' +import TestRunMenu, { TriggerType } from '../test-run-menu' + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) =>
{children}
, + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
, +})) + +vi.mock('../shortcuts-name', () => ({ + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +const createOption = (overrides: Partial = {}): TriggerOption => ({ + id: 'user-input', + type: TriggerType.UserInput, + name: 'User Input', + icon: icon, + enabled: true, + ...overrides, +}) + +describe('TestRunMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should run the only enabled option directly and preserve the child click handler', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const originalOnClick = vi.fn() + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Run now' })) + + expect(originalOnClick).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-input' })) + }) + + it('should expose toggle via ref and select a shortcut when multiple options are available', () => { + const onSelect = vi.fn() + + const Harness = () => { + const ref = React.useRef(null) + + return ( + <> + + + + + + ) + } + + render() + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' })) + }) + fireEvent.keyDown(window, { key: '0' }) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' })) + expect(screen.getByText('~')).toBeInTheDocument() + }) + + it('should ignore disabled options in the rendered menu', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Open menu' })) + + expect(screen.queryByText('User Input')).not.toBeInTheDocument() + expect(screen.getByText('Webhook Trigger')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx new file mode 100644 index 0000000000..dbe6b616a0 --- /dev/null +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -0,0 +1,118 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' +import type { TriggerOption } from './test-run-menu' +import { + cloneElement, + isValidElement, + useEffect, +} from 'react' +import ShortcutsName from '../shortcuts-name' + +export type ShortcutMapping = { + option: TriggerOption + shortcutKey: string +} + +export const getNormalizedShortcutKey = (event: KeyboardEvent) => { + return event.key === '`' ? '~' : event.key +} + +export const OptionRow = ({ + option, + shortcutKey, + onSelect, +}: { + option: TriggerOption + shortcutKey?: string + onSelect: (option: TriggerOption) => void +}) => { + return ( +
onSelect(option)} + > +
+
+ {option.icon} +
+ {option.name} +
+ {shortcutKey && ( + + )} +
+ ) +} + +export const useShortcutMenu = ({ + open, + shortcutMappings, + handleSelect, +}: { + open: boolean + shortcutMappings: ShortcutMapping[] + handleSelect: (option: TriggerOption) => void +}) => { + useEffect(() => { + if (!open) + return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) + return + + const normalizedKey = getNormalizedShortcutKey(event) + const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) + + if (mapping) { + event.preventDefault() + handleSelect(mapping.option) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleSelect, open, shortcutMappings]) +} + +export const SingleOptionTrigger = ({ + children, + runSoleOption, +}: { + children: React.ReactNode + runSoleOption: () => void +}) => { + const handleRunClick = (event?: MouseEvent) => { + if (event?.defaultPrevented) + return + + runSoleOption() + } + + if (isValidElement(children)) { + const childElement = children as ReactElement<{ onClick?: MouseEventHandler }> + const originalOnClick = childElement.props?.onClick + + // eslint-disable-next-line react/no-clone-element + return cloneElement(childElement, { + onClick: (event: MouseEvent) => { + if (typeof originalOnClick === 'function') + originalOnClick(event) + + if (event?.defaultPrevented) + return + + runSoleOption() + }, + }) + } + + return ( + + {children} + + ) +} diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 2cda0501e8..8cffd5417b 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,22 +1,8 @@ -import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' -import { - cloneElement, - forwardRef, - isValidElement, - - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react' +import type { ShortcutMapping } from './test-run-menu-helpers' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -import ShortcutsName from '../shortcuts-name' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { UserInput = 'user_input', @@ -52,9 +38,24 @@ export type TestRunMenuRef = { toggle: () => void } -type ShortcutMapping = { - option: TriggerOption - shortcutKey: string +const getEnabledOptions = (options: TestRunOptions) => { + const flattened: TriggerOption[] = [] + + if (options.userInput) + flattened.push(options.userInput) + if (options.runAll) + flattened.push(options.runAll) + flattened.push(...options.triggers) + + return flattened.filter(option => option.enabled !== false) +} + +const getMenuVisibility = (options: TestRunOptions) => { + return { + hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput), + hasTriggers: options.triggers.some(trigger => trigger.enabled !== false), + hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll), + } } const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { @@ -76,6 +77,7 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { return mappings } +// eslint-disable-next-line react/no-forward-ref const TestRunMenu = forwardRef(({ options, onSelect, @@ -97,17 +99,7 @@ const TestRunMenu = forwardRef(({ setOpen(false) }, [onSelect]) - const enabledOptions = useMemo(() => { - const flattened: TriggerOption[] = [] - - if (options.userInput) - flattened.push(options.userInput) - if (options.runAll) - flattened.push(options.runAll) - flattened.push(...options.triggers) - - return flattened.filter(option => option.enabled !== false) - }, [options]) + const enabledOptions = useMemo(() => getEnabledOptions(options), [options]) const hasSingleEnabledOption = enabledOptions.length === 1 const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined @@ -117,6 +109,12 @@ const TestRunMenu = forwardRef(({ handleSelect(soleEnabledOption) }, [handleSelect, soleEnabledOption]) + useShortcutMenu({ + open, + shortcutMappings, + handleSelect, + }) + useImperativeHandle(ref, () => ({ toggle: () => { if (hasSingleEnabledOption) { @@ -128,84 +126,17 @@ const TestRunMenu = forwardRef(({ }, }), [hasSingleEnabledOption, runSoleOption]) - useEffect(() => { - if (!open) - return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) - return - - const normalizedKey = event.key === '`' ? '~' : event.key - const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) - - if (mapping) { - event.preventDefault() - handleSelect(mapping.option) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => { - window.removeEventListener('keydown', handleKeyDown) - } - }, [handleSelect, open, shortcutMappings]) - const renderOption = (option: TriggerOption) => { - const shortcutKey = shortcutKeyById.get(option.id) - - return ( -
handleSelect(option)} - > -
-
- {option.icon} -
- {option.name} -
- {shortcutKey && ( - - )} -
- ) + return } - const hasUserInput = !!options.userInput && options.userInput.enabled !== false - const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false) - const hasRunAll = !!options.runAll && options.runAll.enabled !== false + const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) if (hasSingleEnabledOption && soleEnabledOption) { - const handleRunClick = (event?: MouseEvent) => { - if (event?.defaultPrevented) - return - - runSoleOption() - } - - if (isValidElement(children)) { - const childElement = children as ReactElement<{ onClick?: MouseEventHandler }> - const originalOnClick = childElement.props?.onClick - - return cloneElement(childElement, { - onClick: (event: MouseEvent) => { - if (typeof originalOnClick === 'function') - originalOnClick(event) - - if (event?.defaultPrevented) - return - - runSoleOption() - }, - }) - } - return ( - + {children} - + ) } diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts index 6c5433cbab..0d88a2b0c3 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -291,6 +291,17 @@ describe('useEdgesInteractions', () => { expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) + it('handleEdgeDeleteById should ignore unknown edge ids', () => { + const { result } = renderEdgesInteractions() + + act(() => { + result.current.handleEdgeDeleteById('missing-edge') + }) + + expect(result.current.edges).toHaveLength(2) + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + }) + it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => { const { result, store } = renderEdgesInteractions({ initialStoreState: { @@ -335,6 +346,46 @@ describe('useEdgesInteractions', () => { }) }) + it('handleEdgeSourceHandleChange should clear edgeMenu and save history for affected edges', async () => { + const { result, store } = renderEdgesInteractions({ + edges: [ + createEdge({ + id: 'n1-old-handle-n2-target', + source: 'n1', + target: 'n2', + sourceHandle: 'old-handle', + targetHandle: 'target', + data: {}, + }), + ], + initialStoreState: { + edgeMenu: { clientX: 120, clientY: 60, edgeId: 'n1-old-handle-n2-target' }, + }, + }) + + act(() => { + result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle') + }) + + await waitFor(() => { + expect(result.current.edges[0]?.sourceHandle).toBe('new-handle') + }) + + expect(store.getState().edgeMenu).toBeUndefined() + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeSourceHandleChange') + }) + + it('handleEdgeSourceHandleChange should do nothing when no edges use the old handle', () => { + const { result } = renderEdgesInteractions() + + act(() => { + result.current.handleEdgeSourceHandleChange('n1', 'missing-handle', 'new-handle') + }) + + expect(result.current.edges.map(edge => edge.id)).toEqual(['e1', 'e2']) + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + }) + describe('read-only mode', () => { beforeEach(() => { mockReadOnly = true @@ -412,5 +463,27 @@ describe('useEdgesInteractions', () => { expect(result.current.edges).toHaveLength(2) }) + + it('handleEdgeSourceHandleChange should do nothing', () => { + const { result } = renderEdgesInteractions({ + edges: [ + createEdge({ + id: 'n1-old-handle-n2-target', + source: 'n1', + target: 'n2', + sourceHandle: 'old-handle', + targetHandle: 'target', + data: {}, + }), + ], + }) + + act(() => { + result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle') + }) + + expect(result.current.edges[0]?.sourceHandle).toBe('old-handle') + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts index d75e39a733..046745112d 100644 --- a/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-helpline.spec.ts @@ -191,4 +191,60 @@ describe('useHelpline', () => { expect(store.getState().helpLineHorizontal).toBeUndefined() }) + + it('should extend horizontal helpline when dragging node is before the first aligned node', () => { + rfState.nodes = [ + { id: 'a', position: { x: 300, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'b', position: { x: 600, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + result.current.handleSetHelpline(makeNode({ id: 'dragging', position: { x: 100, y: 100 } })) + + expect(store.getState().helpLineHorizontal).toEqual({ + top: 100, + left: 100, + width: 440, + }) + }) + + it('should extend vertical helpline when dragging node is below the aligned nodes', () => { + rfState.nodes = [ + { id: 'a', position: { x: 120, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + { id: 'b', position: { x: 120, y: 260 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + result.current.handleSetHelpline(makeNode({ id: 'dragging', position: { x: 120, y: 420 } })) + + expect(store.getState().helpLineVertical).toEqual({ + top: 100, + left: 120, + height: 420, + }) + }) + + it('should extend horizontal helpline using entry node width when a start node is after the aligned nodes', () => { + rfState.nodes = [ + { id: 'aligned', position: { x: 100, y: 100 }, width: 240, height: 100, data: { type: BlockEnum.LLM } }, + ] + + const { result, store } = renderWorkflowHook(() => useHelpline()) + + result.current.handleSetHelpline(makeNode({ + id: 'start-node', + position: { x: 500, y: 79 }, + width: 240, + height: 100, + data: { type: BlockEnum.Start }, + })) + + expect(store.getState().helpLineHorizontal).toEqual({ + top: 100, + left: 100, + width: 640, + }) + }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts index 4ce79d5bf2..a973dc36f7 100644 --- a/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-tool-icon.spec.ts @@ -11,6 +11,8 @@ vi.mock('@/service/use-tools', async () => (await import('../../__tests__/service-mock-factory')).createToolServiceMock({ buildInTools: [{ id: 'builtin-1', name: 'builtin', icon: '/builtin.svg', icon_dark: '/builtin-dark.svg', plugin_id: 'p1' }], customTools: [{ id: 'custom-1', name: 'custom', icon: '/custom.svg', plugin_id: 'p2' }], + workflowTools: [{ id: 'workflow-1', name: 'workflow-tool', icon: '/workflow.svg', plugin_id: 'p3' }], + mcpTools: [{ id: 'mcp-1', name: 'mcp-tool', icon: '/mcp.svg', plugin_id: 'p4' }], })) vi.mock('@/service/use-triggers', async () => @@ -18,8 +20,9 @@ vi.mock('@/service/use-triggers', async () => triggerPlugins: [{ id: 'trigger-1', icon: '/trigger.svg', icon_dark: '/trigger-dark.svg' }], })) +let mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: 'light' }), + default: () => ({ theme: mockTheme }), })) vi.mock('@/utils', () => ({ @@ -31,6 +34,7 @@ const baseNodeData = { title: '', desc: '' } describe('useToolIcon', () => { beforeEach(() => { resetReactFlowMockState() + mockTheme = 'light' }) it('should return empty string when no data', () => { @@ -79,6 +83,60 @@ describe('useToolIcon', () => { expect(result.current).toBe('/custom.svg') }) + it('should use dark trigger and provider icons when available', () => { + mockTheme = 'dark' + + const triggerData = { + ...baseNodeData, + type: BlockEnum.TriggerPlugin, + plugin_id: 'trigger-1', + provider_id: 'trigger-1', + provider_name: 'trigger-1', + } + const providerFallbackData = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'missing-provider', + provider_name: 'missing', + provider_icon: '/fallback.svg', + provider_icon_dark: '/fallback-dark.svg', + } + + expect(renderWorkflowHook(() => useToolIcon(triggerData)).result.current).toBe('/trigger-dark.svg') + expect(renderWorkflowHook(() => useToolIcon(providerFallbackData)).result.current).toBe('/fallback-dark.svg') + }) + + it('should resolve workflow, mcp and datasource icons', () => { + const workflowData = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.workflow, + provider_id: 'workflow-1', + provider_name: 'workflow-tool', + } + const mcpData = { + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.mcp, + provider_id: 'mcp-1', + provider_name: 'mcp-tool', + } + const dataSourceData = { + ...baseNodeData, + type: BlockEnum.DataSource, + plugin_id: 'datasource-1', + } + + expect(renderWorkflowHook(() => useToolIcon(workflowData)).result.current).toBe('/workflow.svg') + expect(renderWorkflowHook(() => useToolIcon(mcpData)).result.current).toBe('/mcp.svg') + expect(renderWorkflowHook(() => useToolIcon(dataSourceData), { + initialStoreState: { + dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource.svg' }] as never, + }, + }).result.current).toBe('/datasource.svg') + }) + it('should fallback to provider_icon when no collection match', () => { const data = { ...baseNodeData, @@ -157,6 +215,29 @@ describe('useGetToolIcon', () => { expect(icon).toBe('/builtin.svg') }) + it('should prefer workflow store collections over query collections', () => { + const { result, store } = renderWorkflowHook(() => useGetToolIcon(), { + initialStoreState: { + buildInTools: [{ id: 'override-1', name: 'override', icon: '/override.svg', plugin_id: 'p1' }] as never, + dataSourceList: [{ id: 'ds-1', plugin_id: 'datasource-1', icon: '/datasource-store.svg' }] as never, + }, + }) + + expect(result.current({ + ...baseNodeData, + type: BlockEnum.Tool, + provider_type: CollectionType.builtIn, + provider_id: 'override-1', + provider_name: 'override', + })).toBe('/override.svg') + expect(result.current({ + ...baseNodeData, + type: BlockEnum.DataSource, + plugin_id: 'datasource-1', + })).toBe('/datasource-store.svg') + expect(store.getState().buildInTools).toHaveLength(1) + }) + it('should return undefined for unmatched node type', () => { const { result } = renderWorkflowHook(() => useGetToolIcon()) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx new file mode 100644 index 0000000000..457b54e763 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx @@ -0,0 +1,329 @@ +import { act } from '@testing-library/react' +import { + createLoopNode, + createNode, +} from '../../__tests__/fixtures' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { ControlMode } from '../../types' +import { + useWorkflowCanvasMaximize, + useWorkflowInteractions, + useWorkflowMoveMode, + useWorkflowOrganize, + useWorkflowUpdate, + useWorkflowZoom, +} from '../use-workflow-interactions' +import * as workflowInteractionExports from '../use-workflow-interactions' + +const mockSetViewport = vi.hoisted(() => vi.fn()) +const mockSetNodes = vi.hoisted(() => vi.fn()) +const mockZoomIn = vi.hoisted(() => vi.fn()) +const mockZoomOut = vi.hoisted(() => vi.fn()) +const mockZoomTo = vi.hoisted(() => vi.fn()) +const mockFitView = vi.hoisted(() => vi.fn()) +const mockEventEmit = vi.hoisted(() => vi.fn()) +const mockHandleSelectionCancel = vi.hoisted(() => vi.fn()) +const mockHandleNodeCancelRunningStatus = vi.hoisted(() => vi.fn()) +const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn()) +const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) +const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) +const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn()) +const mockGetLayoutByDagre = vi.hoisted(() => vi.fn()) +const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes)) +const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges)) + +const runtimeState = vi.hoisted(() => ({ + nodes: [] as ReturnType[], + edges: [] as { id: string, source: string, target: string }[], + nodesReadOnly: false, + workflowReadOnly: false, +})) + +vi.mock('reactflow', () => ({ + Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' }, + useStoreApi: () => ({ + getState: () => ({ + getNodes: () => runtimeState.nodes, + edges: runtimeState.edges, + setNodes: mockSetNodes, + }), + setState: vi.fn(), + }), + useReactFlow: () => ({ + setViewport: mockSetViewport, + zoomIn: mockZoomIn, + zoomOut: mockZoomOut, + zoomTo: mockZoomTo, + fitView: mockFitView, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: (...args: unknown[]) => mockEventEmit(...args), + }, + }), +})) + +vi.mock('../use-workflow', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: () => runtimeState.nodesReadOnly, + nodesReadOnly: runtimeState.nodesReadOnly, + }), + useWorkflowReadOnly: () => ({ + getWorkflowReadOnly: () => runtimeState.workflowReadOnly, + }), +})) + +vi.mock('../use-selection-interactions', () => ({ + useSelectionInteractions: () => ({ + handleSelectionCancel: (...args: unknown[]) => mockHandleSelectionCancel(...args), + }), +})) + +vi.mock('../use-nodes-interactions-without-sync', () => ({ + useNodesInteractionsWithoutSync: () => ({ + handleNodeCancelRunningStatus: (...args: unknown[]) => mockHandleNodeCancelRunningStatus(...args), + }), +})) + +vi.mock('../use-edges-interactions-without-sync', () => ({ + useEdgesInteractionsWithoutSync: () => ({ + handleEdgeCancelRunningStatus: (...args: unknown[]) => mockHandleEdgeCancelRunningStatus(...args), + }), +})) + +vi.mock('../use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args), + }), +})) + +vi.mock('../use-workflow-history', () => ({ + useWorkflowHistory: () => ({ + saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args), + }), + WorkflowHistoryEvent: { + LayoutOrganize: 'LayoutOrganize', + }, +})) + +vi.mock('../../utils', async importOriginal => ({ + ...(await importOriginal()), + getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), + getLayoutByDagre: (...args: unknown[]) => mockGetLayoutByDagre(...args), + initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges), + initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes), +})) + +describe('use-workflow-interactions exports', () => { + it('re-exports the split workflow interaction hooks', () => { + expect(workflowInteractionExports.useWorkflowInteractions).toBeTypeOf('function') + expect(workflowInteractionExports.useWorkflowMoveMode).toBeTypeOf('function') + expect(workflowInteractionExports.useWorkflowOrganize).toBeTypeOf('function') + expect(workflowInteractionExports.useWorkflowZoom).toBeTypeOf('function') + expect(workflowInteractionExports.useWorkflowUpdate).toBeTypeOf('function') + expect(workflowInteractionExports.useWorkflowCanvasMaximize).toBeTypeOf('function') + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + runtimeState.nodes = [] + runtimeState.edges = [] + runtimeState.nodesReadOnly = false + runtimeState.workflowReadOnly = false + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('useWorkflowInteractions should close debug panel and clear running status', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowInteractions(), { + initialStoreState: { + showDebugAndPreviewPanel: true, + workflowRunningData: { task_id: 'task-1' } as never, + }, + }) + + act(() => { + result.current.handleCancelDebugAndPreviewPanel() + }) + + expect(store.getState().showDebugAndPreviewPanel).toBe(false) + expect(store.getState().workflowRunningData).toBeUndefined() + expect(mockHandleNodeCancelRunningStatus).toHaveBeenCalled() + expect(mockHandleEdgeCancelRunningStatus).toHaveBeenCalled() + }) + + it('useWorkflowMoveMode should switch pointer and hand modes when editable', () => { + const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), { + initialStoreState: { + controlMode: ControlMode.Pointer, + }, + }) + + act(() => { + result.current.handleModeHand() + }) + expect(store.getState().controlMode).toBe(ControlMode.Hand) + expect(mockHandleSelectionCancel).toHaveBeenCalled() + + act(() => { + result.current.handleModePointer() + }) + expect(store.getState().controlMode).toBe(ControlMode.Pointer) + }) + + it('useWorkflowOrganize should resize containers, layout nodes and sync draft', async () => { + runtimeState.nodes = [ + createLoopNode({ + id: 'loop-node', + width: 200, + height: 160, + }), + createNode({ + id: 'loop-child', + parentId: 'loop-node', + position: { x: 20, y: 20 }, + width: 100, + height: 60, + }), + createNode({ + id: 'top-node', + position: { x: 400, y: 0 }, + }), + ] + runtimeState.edges = [] + mockGetLayoutForChildNodes.mockResolvedValue({ + bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 }, + nodes: new Map([ + ['loop-child', { x: 40, y: 60, width: 100, height: 60 }], + ]), + }) + mockGetLayoutByDagre.mockResolvedValue({ + nodes: new Map([ + ['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }], + ['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }], + ]), + }) + + const { result } = renderWorkflowHook(() => useWorkflowOrganize()) + + await act(async () => { + await result.current.handleLayout() + }) + act(() => { + vi.runAllTimers() + }) + + expect(mockSetNodes).toHaveBeenCalledTimes(1) + const nextNodes = mockSetNodes.mock.calls[0][0] + expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(expect.objectContaining({ + width: expect.any(Number), + height: expect.any(Number), + position: { x: 10, y: 20 }, + })) + expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(expect.objectContaining({ + position: { x: 100, y: 120 }, + })) + expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 }) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize') + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() + }) + + it('useWorkflowZoom should run zoom actions and sync draft when editable', () => { + const { result } = renderWorkflowHook(() => useWorkflowZoom()) + + act(() => { + result.current.handleFitView() + result.current.handleBackToOriginalSize() + result.current.handleSizeToHalf() + result.current.handleZoomOut() + result.current.handleZoomIn() + }) + + expect(mockFitView).toHaveBeenCalled() + expect(mockZoomTo).toHaveBeenCalledWith(1) + expect(mockZoomTo).toHaveBeenCalledWith(0.5) + expect(mockZoomOut).toHaveBeenCalled() + expect(mockZoomIn).toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(5) + }) + + it('should skip move, zoom, organize and maximize actions when read-only', async () => { + runtimeState.nodesReadOnly = true + runtimeState.workflowReadOnly = true + runtimeState.nodes = [createNode({ id: 'n1' })] + + const moveMode = renderWorkflowHook(() => useWorkflowMoveMode(), { + initialStoreState: { controlMode: ControlMode.Pointer }, + }) + const zoom = renderWorkflowHook(() => useWorkflowZoom()) + const organize = renderWorkflowHook(() => useWorkflowOrganize()) + const maximize = renderWorkflowHook(() => useWorkflowCanvasMaximize()) + + act(() => { + moveMode.result.current.handleModeHand() + moveMode.result.current.handleModePointer() + zoom.result.current.handleFitView() + maximize.result.current.handleToggleMaximizeCanvas() + }) + await act(async () => { + await organize.result.current.handleLayout() + }) + + expect(moveMode.store.getState().controlMode).toBe(ControlMode.Pointer) + expect(mockHandleSelectionCancel).not.toHaveBeenCalled() + expect(mockFitView).not.toHaveBeenCalled() + expect(mockSetViewport).not.toHaveBeenCalled() + expect(localStorage.getItem('workflow-canvas-maximize')).toBeNull() + }) + + it('useWorkflowUpdate should emit initialized data and only set valid viewport', () => { + const { result } = renderWorkflowHook(() => useWorkflowUpdate()) + + act(() => { + result.current.handleUpdateWorkflowCanvas({ + nodes: [createNode({ id: 'n1' })], + edges: [], + viewport: { x: 10, y: 20, zoom: 0.5 }, + } as never) + result.current.handleUpdateWorkflowCanvas({ + nodes: [], + edges: [], + viewport: { x: 'bad' } as never, + }) + }) + + expect(mockInitialNodes).toHaveBeenCalled() + expect(mockInitialEdges).toHaveBeenCalled() + expect(mockEventEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + })) + expect(mockSetViewport).toHaveBeenCalledTimes(1) + expect(mockSetViewport).toHaveBeenCalledWith({ x: 10, y: 20, zoom: 0.5 }) + }) + + it('useWorkflowCanvasMaximize should toggle store and emit event', () => { + localStorage.removeItem('workflow-canvas-maximize') + const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), { + initialStoreState: { + maximizeCanvas: false, + }, + }) + + act(() => { + result.current.handleToggleMaximizeCanvas() + }) + + expect(store.getState().maximizeCanvas).toBe(true) + expect(localStorage.getItem('workflow-canvas-maximize')).toBe('true') + expect(mockEventEmit).toHaveBeenCalledWith({ + type: 'workflow-canvas-maximize', + payload: true, + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts new file mode 100644 index 0000000000..8346d7f977 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.helpers.spec.ts @@ -0,0 +1,123 @@ +import { BlockEnum } from '../../types' +import { + applyContainerSizeChanges, + applyLayoutToNodes, + createLayerMap, + getContainerSizeChanges, + getLayoutContainerNodes, +} from '../use-workflow-organize.helpers' + +type TestNode = { + id: string + type: string + parentId?: string + position: { x: number, y: number } + width: number + height: number + data: { + type: BlockEnum + title: string + desc: string + width?: number + height?: number + } +} + +const createNode = (overrides: Record = {}) => ({ + id: 'node', + type: 'custom', + position: { x: 0, y: 0 }, + width: 100, + height: 80, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + ...overrides, +}) as TestNode + +describe('use-workflow-organize helpers', () => { + it('filters top-level container nodes and computes size changes', () => { + const containers = getLayoutContainerNodes([ + createNode({ id: 'loop', data: { type: BlockEnum.Loop } }), + createNode({ id: 'iteration', data: { type: BlockEnum.Iteration } }), + createNode({ id: 'nested-loop', parentId: 'loop', data: { type: BlockEnum.Loop } }), + createNode({ id: 'code', data: { type: BlockEnum.Code } }), + ]) + expect(containers.map(node => node.id)).toEqual(['loop', 'iteration']) + + const sizeChanges = getContainerSizeChanges(containers, { + loop: { + bounds: { minX: 10, minY: 20, maxX: 180, maxY: 150 }, + nodes: new Map([['child', { x: 10, y: 20, width: 50, height: 40 }]]), + } as unknown as Parameters[1][string], + }) + expect(sizeChanges.loop).toEqual({ width: 290, height: 250 }) + expect(sizeChanges.iteration).toBeUndefined() + }) + + it('creates aligned layers and applies layout positions to root and child nodes', () => { + const rootNodes = [ + createNode({ id: 'root-a' }), + createNode({ id: 'root-b' }), + createNode({ id: 'loop', data: { type: BlockEnum.Loop }, width: 200, height: 180 }), + createNode({ id: 'loop-child', parentId: 'loop' }), + ] + const layout = { + bounds: { minX: 0, minY: 0, maxX: 400, maxY: 300 }, + nodes: new Map([ + ['root-a', { x: 10, y: 100, width: 120, height: 40, layer: 0 }], + ['root-b', { x: 210, y: 120, width: 80, height: 80, layer: 0 }], + ['loop', { x: 320, y: 40, width: 200, height: 180, layer: 1 }], + ]), + } as unknown as Parameters[0] + const childLayoutsMap = { + loop: { + bounds: { minX: 50, minY: 25, maxX: 180, maxY: 90 }, + nodes: new Map([['loop-child', { x: 100, y: 45, width: 80, height: 40 }]]), + }, + } as unknown as Parameters[0]['childLayoutsMap'] + + const layerMap = createLayerMap(layout) + expect(layerMap.get(0)).toEqual({ minY: 100, maxHeight: 80 }) + + const resized = applyContainerSizeChanges(rootNodes, { loop: { width: 260, height: 220 } }) + expect(resized.find(node => node.id === 'loop')).toEqual(expect.objectContaining({ + width: 260, + height: 220, + data: expect.objectContaining({ width: 260, height: 220 }), + })) + + const laidOut = applyLayoutToNodes({ + nodes: rootNodes, + layout, + parentNodes: [rootNodes[2]], + childLayoutsMap, + }) + expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 210, y: 100 }) + expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 110, y: 80 }) + }) + + it('keeps original positions when layer or child layout data is missing', () => { + const nodes = [ + createNode({ id: 'root-a', position: { x: 1, y: 2 } }), + createNode({ id: 'root-b', position: { x: 3, y: 4 } }), + createNode({ id: 'loop', data: { type: BlockEnum.Loop }, position: { x: 5, y: 6 } }), + createNode({ id: 'loop-child', parentId: 'loop', position: { x: 7, y: 8 } }), + ] + const layout = { + bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 }, + nodes: new Map([ + ['root-a', { x: 20, y: 30, width: 50, height: 20 }], + ]), + } as unknown as Parameters[0]['layout'] + + const laidOut = applyLayoutToNodes({ + nodes, + layout, + parentNodes: [nodes[2]], + childLayoutsMap: {}, + }) + + expect(laidOut.find(node => node.id === 'root-a')?.position).toEqual({ x: 20, y: 30 }) + expect(laidOut.find(node => node.id === 'root-b')?.position).toEqual({ x: 3, y: 4 }) + expect(laidOut.find(node => node.id === 'loop-child')?.position).toEqual({ x: 7, y: 8 }) + }) +}) diff --git a/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts new file mode 100644 index 0000000000..a19380b58b --- /dev/null +++ b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts @@ -0,0 +1,77 @@ +import type { Edge, EdgeChange } from 'reactflow' +import type { Node } from '../types' +import { produce } from 'immer' +import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils' + +export const applyConnectedHandleNodeData = ( + nodes: Node[], + edgeChanges: Parameters[0], +) => { + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes) + + return produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { + node.data = { + ...node.data, + ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], + } + } + }) + }) +} + +export const clearEdgeMenuIfNeeded = ({ + edgeMenu, + edgeIds, +}: { + edgeMenu?: { + edgeId: string + } + edgeIds: string[] +}) => { + return !!(edgeMenu && edgeIds.includes(edgeMenu.edgeId)) +} + +export const updateEdgeHoverState = ( + edges: Edge[], + edgeId: string, + hovering: boolean, +) => produce(edges, (draft) => { + const currentEdge = draft.find(edge => edge.id === edgeId) + if (currentEdge) + currentEdge.data._hovering = hovering +}) + +export const updateEdgeSelectionState = ( + edges: Edge[], + changes: EdgeChange[], +) => produce(edges, (draft) => { + changes.forEach((change) => { + if (change.type === 'select') { + const currentEdge = draft.find(edge => edge.id === change.id) + if (currentEdge) + currentEdge.selected = change.selected + } + }) +}) + +export const buildContextMenuEdges = ( + edges: Edge[], + edgeId: string, +) => produce(edges, (draft) => { + draft.forEach((item) => { + item.selected = item.id === edgeId + if (item.data._isBundled) + item.data._isBundled = false + }) +}) + +export const clearNodeSelectionState = (nodes: Node[]) => produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + node.data.selected = false + if (node.data._isBundled) + node.data._isBundled = false + node.selected = false + }) +}) diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index 484e552ba2..60685081a1 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -2,16 +2,20 @@ import type { EdgeMouseHandler, OnEdgesChange, } from 'reactflow' -import type { - Node, -} from '../types' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi, } from 'reactflow' import { useWorkflowStore } from '../store' -import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils' +import { + applyConnectedHandleNodeData, + buildContextMenuEdges, + clearEdgeMenuIfNeeded, + clearNodeSelectionState, + updateEdgeHoverState, + updateEdgeSelectionState, +} from './use-edges-interactions.helpers' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' @@ -36,29 +40,13 @@ export const useEdgesInteractions = () => { return const currentEdge = edges[currentEdgeIndex] const nodes = getNodes() - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - [ - { type: 'remove', edge: currentEdge }, - ], - nodes, - ) - const newNodes = produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { - node.data = { - ...node.data, - ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], - } - } - }) - }) + const newNodes = applyConnectedHandleNodeData(nodes, [{ type: 'remove', edge: currentEdge }]) setNodes(newNodes) const newEdges = produce(edges, (draft) => { draft.splice(currentEdgeIndex, 1) }) setEdges(newEdges) - const currentEdgeMenu = workflowStore.getState().edgeMenu - if (currentEdgeMenu?.edgeId === currentEdge.id) + if (clearEdgeMenuIfNeeded({ edgeMenu: workflowStore.getState().edgeMenu, edgeIds: [currentEdge.id] })) workflowStore.setState({ edgeMenu: undefined }) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) @@ -72,12 +60,7 @@ export const useEdgesInteractions = () => { edges, setEdges, } = store.getState() - const newEdges = produce(edges, (draft) => { - const currentEdge = draft.find(e => e.id === edge.id)! - - currentEdge.data._hovering = true - }) - setEdges(newEdges) + setEdges(updateEdgeHoverState(edges, edge.id, true)) }, [store, getNodesReadOnly]) const handleEdgeLeave = useCallback((_, edge) => { @@ -88,12 +71,7 @@ export const useEdgesInteractions = () => { edges, setEdges, } = store.getState() - const newEdges = produce(edges, (draft) => { - const currentEdge = draft.find(e => e.id === edge.id)! - - currentEdge.data._hovering = false - }) - setEdges(newEdges) + setEdges(updateEdgeHoverState(edges, edge.id, false)) }, [store, getNodesReadOnly]) const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => { @@ -112,28 +90,21 @@ export const useEdgesInteractions = () => { return const nodes = getNodes() - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })), + const newNodes = applyConnectedHandleNodeData( nodes, + edgeWillBeDeleted.map(edge => ({ type: 'remove' as const, edge })), ) - const newNodes = produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { - node.data = { - ...node.data, - ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], - } - } - }) - }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id)) }) setEdges(newEdges) - const currentEdgeMenu = workflowStore.getState().edgeMenu - if (currentEdgeMenu && edgeWillBeDeleted.some(edge => edge.id === currentEdgeMenu.edgeId)) + if (clearEdgeMenuIfNeeded({ + edgeMenu: workflowStore.getState().edgeMenu, + edgeIds: edgeWillBeDeleted.map(edge => edge.id), + })) { workflowStore.setState({ edgeMenu: undefined }) + } handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch) }, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) @@ -165,14 +136,7 @@ export const useEdgesInteractions = () => { edges, setEdges, } = store.getState() - - const newEdges = produce(edges, (draft) => { - changes.forEach((change) => { - if (change.type === 'select') - draft.find(edge => edge.id === change.id)!.selected = change.selected - }) - }) - setEdges(newEdges) + setEdges(updateEdgeSelectionState(edges, changes)) }, [store, getNodesReadOnly]) const handleEdgeSourceHandleChange = useCallback((nodeId: string, oldHandleId: string, newHandleId: string) => { @@ -191,27 +155,13 @@ export const useEdgesInteractions = () => { return // Update node metadata: remove old handle, add new handle - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - [ - ...affectedEdges.map(edge => ({ type: 'remove', edge })), - ...affectedEdges.map(edge => ({ - type: 'add', - edge: { ...edge, sourceHandle: newHandleId }, - })), - ], - nodes, - ) - - const newNodes = produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { - node.data = { - ...node.data, - ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], - } - } - }) - }) + const newNodes = applyConnectedHandleNodeData(nodes, [ + ...affectedEdges.map(edge => ({ type: 'remove' as const, edge })), + ...affectedEdges.map(edge => ({ + type: 'add' as const, + edge: { ...edge, sourceHandle: newHandleId }, + })), + ]) setNodes(newNodes) // Update edges to use new sourceHandle and regenerate edge IDs @@ -224,9 +174,12 @@ export const useEdgesInteractions = () => { }) }) setEdges(newEdges) - const currentEdgeMenu = workflowStore.getState().edgeMenu - if (currentEdgeMenu && !newEdges.some(edge => edge.id === currentEdgeMenu.edgeId)) + if (clearEdgeMenuIfNeeded({ + edgeMenu: workflowStore.getState().edgeMenu, + edgeIds: affectedEdges.map(edge => edge.id), + })) { workflowStore.setState({ edgeMenu: undefined }) + } handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange) }, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) @@ -238,25 +191,10 @@ export const useEdgesInteractions = () => { e.preventDefault() const { getNodes, setNodes, edges, setEdges } = store.getState() - const newEdges = produce(edges, (draft) => { - draft.forEach((item) => { - item.selected = item.id === edge.id - if (item.data._isBundled) - item.data._isBundled = false - }) - }) - setEdges(newEdges) + setEdges(buildContextMenuEdges(edges, edge.id)) const nodes = getNodes() if (nodes.some(node => node.data.selected || node.selected || node.data._isBundled)) { - const newNodes = produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - node.data.selected = false - if (node.data._isBundled) - node.data._isBundled = false - node.selected = false - }) - }) - setNodes(newNodes) + setNodes(clearNodeSelectionState(nodes)) } workflowStore.setState({ diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index 681a7c9802..d039d10128 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -12,6 +12,132 @@ const ENTRY_NODE_WRAPPER_OFFSET = { y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px) } as const +type HelpLineNodeCollections = { + showHorizontalHelpLineNodes: Node[] + showVerticalHelpLineNodes: Node[] +} + +type NodeAlignPosition = { + x: number + y: number +} + +const ALIGN_THRESHOLD = 5 + +const getEntryNodeDimension = ( + node: Node, + dimension: 'width' | 'height', +) => { + const offset = dimension === 'width' + ? ENTRY_NODE_WRAPPER_OFFSET.x + : ENTRY_NODE_WRAPPER_OFFSET.y + + return (node[dimension] ?? 0) - offset +} + +const getAlignedNodes = ({ + nodes, + node, + nodeAlignPos, + axis, + getNodeAlignPosition, +}: { + nodes: Node[] + node: Node + nodeAlignPos: NodeAlignPosition + axis: 'x' | 'y' + getNodeAlignPosition: (node: Node) => NodeAlignPosition +}) => { + return nodes.filter((candidate) => { + if (candidate.id === node.id) + return false + if (candidate.data.isInIteration || candidate.data.isInLoop) + return false + + const candidateAlignPos = getNodeAlignPosition(candidate) + const diff = Math.ceil(candidateAlignPos[axis]) - Math.ceil(nodeAlignPos[axis]) + return diff < ALIGN_THRESHOLD && diff > -ALIGN_THRESHOLD + }).sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) +} + +const buildHorizontalHelpLine = ({ + alignedNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, +}: { + alignedNodes: Node[] + node: Node + nodeAlignPos: NodeAlignPosition + getNodeAlignPosition: (node: Node) => NodeAlignPosition + isEntryNode: (node: Node) => boolean +}) => { + if (!alignedNodes.length) + return undefined + + const first = alignedNodes[0] + const last = alignedNodes[alignedNodes.length - 1] + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + const helpLine = { + top: firstPos.y, + left: firstPos.x, + width: lastPos.x + (isEntryNode(last) ? getEntryNodeDimension(last, 'width') : last.width ?? 0) - firstPos.x, + } + + if (nodeAlignPos.x < firstPos.x) { + helpLine.left = nodeAlignPos.x + helpLine.width = firstPos.x + (isEntryNode(first) ? getEntryNodeDimension(first, 'width') : first.width ?? 0) - nodeAlignPos.x + } + + if (nodeAlignPos.x > lastPos.x) + helpLine.width = nodeAlignPos.x + (isEntryNode(node) ? getEntryNodeDimension(node, 'width') : node.width ?? 0) - firstPos.x + + return helpLine +} + +const buildVerticalHelpLine = ({ + alignedNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, +}: { + alignedNodes: Node[] + node: Node + nodeAlignPos: NodeAlignPosition + getNodeAlignPosition: (node: Node) => NodeAlignPosition + isEntryNode: (node: Node) => boolean +}) => { + if (!alignedNodes.length) + return undefined + + const first = alignedNodes[0] + const last = alignedNodes[alignedNodes.length - 1] + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + const helpLine = { + top: firstPos.y, + left: firstPos.x, + height: lastPos.y + (isEntryNode(last) ? getEntryNodeDimension(last, 'height') : last.height ?? 0) - firstPos.y, + } + + if (nodeAlignPos.y < firstPos.y) { + helpLine.top = nodeAlignPos.y + helpLine.height = firstPos.y + (isEntryNode(first) ? getEntryNodeDimension(first, 'height') : first.height ?? 0) - nodeAlignPos.y + } + + if (nodeAlignPos.y > lastPos.y) + helpLine.height = nodeAlignPos.y + (isEntryNode(node) ? getEntryNodeDimension(node, 'height') : node.height ?? 0) - firstPos.y + + return helpLine +} + export const useHelpline = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() @@ -60,135 +186,41 @@ export const useHelpline = () => { // Get the actual alignment position for the dragging node const nodeAlignPos = getNodeAlignPosition(node) - const showHorizontalHelpLineNodes = nodes.filter((n) => { - if (n.id === node.id) - return false - - if (n.data.isInIteration) - return false - - if (n.data.isInLoop) - return false - - // Get actual alignment position for comparison node - const nAlignPos = getNodeAlignPosition(n) - const nY = Math.ceil(nAlignPos.y) - const nodeY = Math.ceil(nodeAlignPos.y) - - if (nY - nodeY < 5 && nY - nodeY > -5) - return true - - return false - }).sort((a, b) => { - const aPos = getNodeAlignPosition(a) - const bPos = getNodeAlignPosition(b) - return aPos.x - bPos.x + const showHorizontalHelpLineNodes = getAlignedNodes({ + nodes, + node, + nodeAlignPos, + axis: 'y', + getNodeAlignPosition, + }) + const showVerticalHelpLineNodes = getAlignedNodes({ + nodes, + node, + nodeAlignPos, + axis: 'x', + getNodeAlignPosition, }) - const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length - if (showHorizontalHelpLineNodesLength > 0) { - const first = showHorizontalHelpLineNodes[0] - const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1] - - // Use actual alignment positions for help line rendering - const firstPos = getNodeAlignPosition(first) - const lastPos = getNodeAlignPosition(last) - - // For entry nodes, we need to subtract the offset from width since lastPos already includes it - const lastIsEntryNode = isEntryNode(last) - const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width! - - const helpLine = { - top: firstPos.y, - left: firstPos.x, - width: lastPos.x + lastNodeWidth - firstPos.x, - } - - if (nodeAlignPos.x < firstPos.x) { - const firstIsEntryNode = isEntryNode(first) - const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width! - helpLine.left = nodeAlignPos.x - helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x - } - - if (nodeAlignPos.x > lastPos.x) { - const nodeIsEntryNode = isEntryNode(node) - const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width! - helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x - } - - setHelpLineHorizontal(helpLine) - } - else { - setHelpLineHorizontal() - } - - const showVerticalHelpLineNodes = nodes.filter((n) => { - if (n.id === node.id) - return false - if (n.data.isInIteration) - return false - if (n.data.isInLoop) - return false - - // Get actual alignment position for comparison node - const nAlignPos = getNodeAlignPosition(n) - const nX = Math.ceil(nAlignPos.x) - const nodeX = Math.ceil(nodeAlignPos.x) - - if (nX - nodeX < 5 && nX - nodeX > -5) - return true - - return false - }).sort((a, b) => { - const aPos = getNodeAlignPosition(a) - const bPos = getNodeAlignPosition(b) - return aPos.x - bPos.x - }) - const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length - - if (showVerticalHelpLineNodesLength > 0) { - const first = showVerticalHelpLineNodes[0] - const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1] - - // Use actual alignment positions for help line rendering - const firstPos = getNodeAlignPosition(first) - const lastPos = getNodeAlignPosition(last) - - // For entry nodes, we need to subtract the offset from height since lastPos already includes it - const lastIsEntryNode = isEntryNode(last) - const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height! - - const helpLine = { - top: firstPos.y, - left: firstPos.x, - height: lastPos.y + lastNodeHeight - firstPos.y, - } - - if (nodeAlignPos.y < firstPos.y) { - const firstIsEntryNode = isEntryNode(first) - const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height! - helpLine.top = nodeAlignPos.y - helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y - } - - if (nodeAlignPos.y > lastPos.y) { - const nodeIsEntryNode = isEntryNode(node) - const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height! - helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y - } - - setHelpLineVertical(helpLine) - } - else { - setHelpLineVertical() - } + setHelpLineHorizontal(buildHorizontalHelpLine({ + alignedNodes: showHorizontalHelpLineNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, + })) + setHelpLineVertical(buildVerticalHelpLine({ + alignedNodes: showVerticalHelpLineNodes, + node, + nodeAlignPos, + getNodeAlignPosition, + isEntryNode, + })) return { showHorizontalHelpLineNodes, showVerticalHelpLineNodes, - } - }, [store, workflowStore, getNodeAlignPosition]) + } satisfies HelpLineNodeCollections + }, [store, workflowStore, getNodeAlignPosition, isEntryNode]) return { handleSetHelpline, diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index a300021bad..6dec65974c 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -24,6 +24,12 @@ const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === B const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource type IconValue = ToolWithProvider['icon'] +type ToolCollections = { + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] +} const resolveIconByTheme = ( currentTheme: string | undefined, @@ -51,6 +57,121 @@ const findTriggerPluginIcon = ( return undefined } +const getPrimaryToolCollection = ( + providerType: CollectionType | undefined, + collections: ToolCollections, +) => { + switch (providerType) { + case CollectionType.custom: + return collections.customTools + case CollectionType.mcp: + return collections.mcpTools + case CollectionType.workflow: + return collections.workflowTools + case CollectionType.builtIn: + default: + return collections.buildInTools + } +} + +const getCollectionsToSearch = ( + providerType: CollectionType | undefined, + collections: ToolCollections, +) => { + return [ + getPrimaryToolCollection(providerType, collections), + collections.buildInTools, + collections.customTools, + collections.workflowTools, + collections.mcpTools, + ] as Array +} + +const findToolInCollections = ( + collections: Array, + data: ToolNodeType, +) => { + const seen = new Set() + + for (const collection of collections) { + if (!collection || seen.has(collection)) + continue + + seen.add(collection) + const matched = collection.find((toolWithProvider) => { + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + return data.provider_name === toolWithProvider.name + }) + + if (matched) + return matched + } + + return undefined +} + +const findToolNodeIcon = ({ + data, + collections, + theme, +}: { + data: ToolNodeType + collections: ToolCollections + theme?: string +}) => { + const matched = findToolInCollections(getCollectionsToSearch(data.provider_type, collections), data) + if (matched) { + const matchedIcon = resolveIconByTheme(theme, matched.icon, matched.icon_dark) + if (matchedIcon) + return matchedIcon + } + + return resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark) +} + +const findDataSourceIcon = ( + data: DataSourceNodeType, + dataSourceList?: ToolWithProvider[], +) => { + return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon +} + +const findNodeIcon = ({ + data, + collections, + dataSourceList, + triggerPlugins, + theme, +}: { + data?: Node['data'] + collections: ToolCollections + dataSourceList?: ToolWithProvider[] + triggerPlugins?: TriggerWithProvider[] + theme?: string +}) => { + if (!data) + return undefined + + if (isTriggerPluginNode(data)) { + return findTriggerPluginIcon( + [data.plugin_id, data.provider_id, data.provider_name], + triggerPlugins, + theme, + ) + } + + if (isToolNode(data)) + return findToolNodeIcon({ data, collections, theme }) + + if (isDataSourceNode(data)) + return findDataSourceIcon(data, dataSourceList) + + return undefined +} + export const useToolIcon = (data?: Node['data']) => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() @@ -61,79 +182,18 @@ export const useToolIcon = (data?: Node['data']) => { const { theme } = useTheme() const toolIcon = useMemo(() => { - if (!data) - return '' - - if (isTriggerPluginNode(data)) { - const icon = findTriggerPluginIcon( - [ - data.plugin_id, - data.provider_id, - data.provider_name, - ], - triggerPlugins, - theme, - ) - if (icon) - return icon - } - - if (isToolNode(data)) { - let primaryCollection: ToolWithProvider[] | undefined - switch (data.provider_type) { - case CollectionType.custom: - primaryCollection = customTools - break - case CollectionType.mcp: - primaryCollection = mcpTools - break - case CollectionType.workflow: - primaryCollection = workflowTools - break - case CollectionType.builtIn: - default: - primaryCollection = buildInTools - break - } - - const collectionsToSearch = [ - primaryCollection, + return findNodeIcon({ + data, + collections: { buildInTools, customTools, workflowTools, mcpTools, - ] as Array - - const seen = new Set() - for (const collection of collectionsToSearch) { - if (!collection || seen.has(collection)) - continue - seen.add(collection) - const matched = collection.find((toolWithProvider) => { - if (canFindTool(toolWithProvider.id, data.provider_id)) - return true - if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) - return true - return data.provider_name === toolWithProvider.name - }) - if (matched) { - const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark) - if (icon) - return icon - } - } - - const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark) - if (fallbackIcon) - return fallbackIcon - - return '' - } - - if (isDataSourceNode(data)) - return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || '' - - return '' + }, + dataSourceList, + triggerPlugins, + theme, + }) || '' }, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, theme]) return toolIcon @@ -157,71 +217,18 @@ export const useGetToolIcon = () => { dataSourceList, } = workflowStore.getState() - if (isTriggerPluginNode(data)) { - return findTriggerPluginIcon( - [ - data.plugin_id, - data.provider_id, - data.provider_name, - ], - triggerPlugins, - theme, - ) - } - - if (isToolNode(data)) { - const primaryCollection = (() => { - switch (data.provider_type) { - case CollectionType.custom: - return storeCustomTools ?? customTools - case CollectionType.mcp: - return storeMcpTools ?? mcpTools - case CollectionType.workflow: - return storeWorkflowTools ?? workflowTools - case CollectionType.builtIn: - default: - return storeBuiltInTools ?? buildInTools - } - })() - - const collectionsToSearch = [ - primaryCollection, - storeBuiltInTools ?? buildInTools, - storeCustomTools ?? customTools, - storeWorkflowTools ?? workflowTools, - storeMcpTools ?? mcpTools, - ] as Array - - const seen = new Set() - for (const collection of collectionsToSearch) { - if (!collection || seen.has(collection)) - continue - seen.add(collection) - const matched = collection.find((toolWithProvider) => { - if (canFindTool(toolWithProvider.id, data.provider_id)) - return true - if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) - return true - return data.provider_name === toolWithProvider.name - }) - if (matched) { - const icon = resolveIconByTheme(theme, matched.icon, matched.icon_dark) - if (icon) - return icon - } - } - - const fallbackIcon = resolveIconByTheme(theme, data.provider_icon, data.provider_icon_dark) - if (fallbackIcon) - return fallbackIcon - - return undefined - } - - if (isDataSourceNode(data)) - return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon - - return undefined + return findNodeIcon({ + data, + collections: { + buildInTools: storeBuiltInTools ?? buildInTools, + customTools: storeCustomTools ?? customTools, + workflowTools: storeWorkflowTools ?? workflowTools, + mcpTools: storeMcpTools ?? mcpTools, + }, + dataSourceList, + triggerPlugins, + theme, + }) }, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools, theme]) return getToolIcon diff --git a/web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts b/web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts new file mode 100644 index 0000000000..a2638afbe4 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { useStore } from '../store' +import { useNodesReadOnly } from './use-workflow' + +export const useWorkflowCanvasMaximize = () => { + const { eventEmitter } = useEventEmitterContextContext() + const maximizeCanvas = useStore(s => s.maximizeCanvas) + const setMaximizeCanvas = useStore(s => s.setMaximizeCanvas) + const { getNodesReadOnly } = useNodesReadOnly() + + const handleToggleMaximizeCanvas = useCallback(() => { + if (getNodesReadOnly()) + return + + const nextValue = !maximizeCanvas + setMaximizeCanvas(nextValue) + localStorage.setItem('workflow-canvas-maximize', String(nextValue)) + eventEmitter?.emit({ + type: 'workflow-canvas-maximize', + payload: nextValue, + } as never) + }, [eventEmitter, getNodesReadOnly, maximizeCanvas, setMaximizeCanvas]) + + return { + handleToggleMaximizeCanvas, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index 7a58581a99..3ad3869418 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -1,355 +1,5 @@ -import type { WorkflowDataUpdater } from '../types' -import type { LayoutResult } from '../utils' -import { produce } from 'immer' -import { - useCallback, -} from 'react' -import { useReactFlow, useStoreApi } from 'reactflow' -import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - CUSTOM_NODE, - NODE_LAYOUT_HORIZONTAL_PADDING, - NODE_LAYOUT_VERTICAL_PADDING, - WORKFLOW_DATA_UPDATE, -} from '../constants' -import { - useNodesReadOnly, - useSelectionInteractions, - useWorkflowReadOnly, -} from '../hooks' -import { useStore, useWorkflowStore } from '../store' -import { BlockEnum, ControlMode } from '../types' -import { - getLayoutByDagre, - getLayoutForChildNodes, - initialEdges, - initialNodes, -} from '../utils' -import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync' -import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync' -import { useNodesSyncDraft } from './use-nodes-sync-draft' -import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' - -export const useWorkflowInteractions = () => { - const workflowStore = useWorkflowStore() - const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync() - const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() - - const handleCancelDebugAndPreviewPanel = useCallback(() => { - workflowStore.setState({ - showDebugAndPreviewPanel: false, - workflowRunningData: undefined, - }) - handleNodeCancelRunningStatus() - handleEdgeCancelRunningStatus() - }, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus]) - - return { - handleCancelDebugAndPreviewPanel, - } -} - -export const useWorkflowMoveMode = () => { - const setControlMode = useStore(s => s.setControlMode) - const { - getNodesReadOnly, - } = useNodesReadOnly() - const { handleSelectionCancel } = useSelectionInteractions() - - const handleModePointer = useCallback(() => { - if (getNodesReadOnly()) - return - - setControlMode(ControlMode.Pointer) - }, [getNodesReadOnly, setControlMode]) - - const handleModeHand = useCallback(() => { - if (getNodesReadOnly()) - return - - setControlMode(ControlMode.Hand) - handleSelectionCancel() - }, [getNodesReadOnly, setControlMode, handleSelectionCancel]) - - return { - handleModePointer, - handleModeHand, - } -} - -export const useWorkflowOrganize = () => { - const workflowStore = useWorkflowStore() - const store = useStoreApi() - const reactflow = useReactFlow() - const { getNodesReadOnly } = useNodesReadOnly() - const { saveStateToHistory } = useWorkflowHistory() - const { handleSyncWorkflowDraft } = useNodesSyncDraft() - - const handleLayout = useCallback(async () => { - if (getNodesReadOnly()) - return - workflowStore.setState({ nodeAnimation: true }) - const { - getNodes, - edges, - setNodes, - } = store.getState() - const { setViewport } = reactflow - const nodes = getNodes() - - const loopAndIterationNodes = nodes.filter( - node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) - && !node.parentId - && node.type === CUSTOM_NODE, - ) - - const childLayoutEntries = await Promise.all( - loopAndIterationNodes.map(async node => [ - node.id, - await getLayoutForChildNodes(node.id, nodes, edges), - ] as const), - ) - const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => { - if (layout) - acc[nodeId] = layout - return acc - }, {} as Record) - - const containerSizeChanges: Record = {} - - loopAndIterationNodes.forEach((parentNode) => { - const childLayout = childLayoutsMap[parentNode.id] - if (!childLayout) - return - - const { - bounds, - nodes: layoutNodes, - } = childLayout - - if (!layoutNodes.size) - return - - const requiredWidth = (bounds.maxX - bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2 - const requiredHeight = (bounds.maxY - bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2 - - containerSizeChanges[parentNode.id] = { - width: Math.max(parentNode.width || 0, requiredWidth), - height: Math.max(parentNode.height || 0, requiredHeight), - } - }) - - const nodesWithUpdatedSizes = produce(nodes, (draft) => { - draft.forEach((node) => { - if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) - && containerSizeChanges[node.id]) { - node.width = containerSizeChanges[node.id].width - node.height = containerSizeChanges[node.id].height - - if (node.data.type === BlockEnum.Loop) { - node.data.width = containerSizeChanges[node.id].width - node.data.height = containerSizeChanges[node.id].height - } - else if (node.data.type === BlockEnum.Iteration) { - node.data.width = containerSizeChanges[node.id].width - node.data.height = containerSizeChanges[node.id].height - } - } - }) - }) - - const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges) - - // Build layer map for vertical alignment - nodes in the same layer should align - const layerMap = new Map() - layout.nodes.forEach((layoutInfo) => { - if (layoutInfo.layer !== undefined) { - const existing = layerMap.get(layoutInfo.layer) - const newLayerInfo = { - minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y, - maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height, - } - layerMap.set(layoutInfo.layer, newLayerInfo) - } - }) - - const newNodes = produce(nodesWithUpdatedSizes, (draft) => { - draft.forEach((node) => { - if (!node.parentId && node.type === CUSTOM_NODE) { - const layoutInfo = layout.nodes.get(node.id) - if (!layoutInfo) - return - - // Calculate vertical position with layer alignment - let yPosition = layoutInfo.y - if (layoutInfo.layer !== undefined) { - const layerInfo = layerMap.get(layoutInfo.layer) - if (layerInfo) { - // Align to the center of the tallest node in this layer - const layerCenterY = layerInfo.minY + layerInfo.maxHeight / 2 - yPosition = layerCenterY - layoutInfo.height / 2 - } - } - - node.position = { - x: layoutInfo.x, - y: yPosition, - } - } - }) - - loopAndIterationNodes.forEach((parentNode) => { - const childLayout = childLayoutsMap[parentNode.id] - if (!childLayout) - return - - const childNodes = draft.filter(node => node.parentId === parentNode.id) - const { - bounds, - nodes: layoutNodes, - } = childLayout - - childNodes.forEach((childNode) => { - const layoutInfo = layoutNodes.get(childNode.id) - if (!layoutInfo) - return - - childNode.position = { - x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - bounds.minX), - y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - bounds.minY), - } - }) - }) - }) - - setNodes(newNodes) - const zoom = 0.7 - setViewport({ - x: 0, - y: 0, - zoom, - }) - saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize) - setTimeout(() => { - handleSyncWorkflowDraft() - }) - }, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) - - return { - handleLayout, - } -} - -export const useWorkflowZoom = () => { - const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const { getWorkflowReadOnly } = useWorkflowReadOnly() - const { - zoomIn, - zoomOut, - zoomTo, - fitView, - } = useReactFlow() - - const handleFitView = useCallback(() => { - if (getWorkflowReadOnly()) - return - - fitView() - handleSyncWorkflowDraft() - }, [getWorkflowReadOnly, fitView, handleSyncWorkflowDraft]) - - const handleBackToOriginalSize = useCallback(() => { - if (getWorkflowReadOnly()) - return - - zoomTo(1) - handleSyncWorkflowDraft() - }, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft]) - - const handleSizeToHalf = useCallback(() => { - if (getWorkflowReadOnly()) - return - - zoomTo(0.5) - handleSyncWorkflowDraft() - }, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft]) - - const handleZoomOut = useCallback(() => { - if (getWorkflowReadOnly()) - return - - zoomOut() - handleSyncWorkflowDraft() - }, [getWorkflowReadOnly, zoomOut, handleSyncWorkflowDraft]) - - const handleZoomIn = useCallback(() => { - if (getWorkflowReadOnly()) - return - - zoomIn() - handleSyncWorkflowDraft() - }, [getWorkflowReadOnly, zoomIn, handleSyncWorkflowDraft]) - - return { - handleFitView, - handleBackToOriginalSize, - handleSizeToHalf, - handleZoomOut, - handleZoomIn, - } -} - -export const useWorkflowUpdate = () => { - const reactflow = useReactFlow() - const { eventEmitter } = useEventEmitterContextContext() - - const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => { - const { - nodes, - edges, - viewport, - } = payload - const { setViewport } = reactflow - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - }, - } as any) - - // Only set viewport if it exists and is valid - if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number') - setViewport(viewport) - }, [eventEmitter, reactflow]) - - return { - handleUpdateWorkflowCanvas, - } -} - -export const useWorkflowCanvasMaximize = () => { - const { eventEmitter } = useEventEmitterContextContext() - - const maximizeCanvas = useStore(s => s.maximizeCanvas) - const setMaximizeCanvas = useStore(s => s.setMaximizeCanvas) - const { - getNodesReadOnly, - } = useNodesReadOnly() - - const handleToggleMaximizeCanvas = useCallback(() => { - if (getNodesReadOnly()) - return - - setMaximizeCanvas(!maximizeCanvas) - localStorage.setItem('workflow-canvas-maximize', String(!maximizeCanvas)) - eventEmitter?.emit({ - type: 'workflow-canvas-maximize', - payload: !maximizeCanvas, - } as any) - }, [eventEmitter, getNodesReadOnly, maximizeCanvas, setMaximizeCanvas]) - - return { - handleToggleMaximizeCanvas, - } -} +export { useWorkflowCanvasMaximize } from './use-workflow-canvas-maximize' +export { useWorkflowOrganize } from './use-workflow-organize' +export { useWorkflowInteractions, useWorkflowMoveMode } from './use-workflow-panel-interactions' +export { useWorkflowUpdate } from './use-workflow-update' +export { useWorkflowZoom } from './use-workflow-zoom' diff --git a/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts new file mode 100644 index 0000000000..c95e003e7f --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts @@ -0,0 +1,138 @@ +import type { Node } from '../types' +import type { LayoutResult } from '../utils' +import { produce } from 'immer' +import { + CUSTOM_NODE, + NODE_LAYOUT_HORIZONTAL_PADDING, + NODE_LAYOUT_VERTICAL_PADDING, +} from '../constants' +import { BlockEnum } from '../types' + +type ContainerSizeChange = { + width: number + height: number +} + +type LayerInfo = { + minY: number + maxHeight: number +} + +export const getLayoutContainerNodes = (nodes: Node[]) => { + return nodes.filter( + node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) + && !node.parentId + && node.type === CUSTOM_NODE, + ) +} + +export const getContainerSizeChanges = ( + parentNodes: Node[], + childLayoutsMap: Record, +) => { + return parentNodes.reduce>((acc, parentNode) => { + const childLayout = childLayoutsMap[parentNode.id] + if (!childLayout || !childLayout.nodes.size) + return acc + + const requiredWidth = (childLayout.bounds.maxX - childLayout.bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2 + const requiredHeight = (childLayout.bounds.maxY - childLayout.bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2 + + acc[parentNode.id] = { + width: Math.max(parentNode.width || 0, requiredWidth), + height: Math.max(parentNode.height || 0, requiredHeight), + } + return acc + }, {}) +} + +export const applyContainerSizeChanges = ( + nodes: Node[], + containerSizeChanges: Record, +) => produce(nodes, (draft) => { + draft.forEach((node) => { + const nextSize = containerSizeChanges[node.id] + if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration) && nextSize) { + node.width = nextSize.width + node.height = nextSize.height + node.data.width = nextSize.width + node.data.height = nextSize.height + } + }) +}) + +export const createLayerMap = (layout: LayoutResult) => { + return Array.from(layout.nodes.values()).reduce>((acc, layoutInfo) => { + if (layoutInfo.layer === undefined) + return acc + + const existing = acc.get(layoutInfo.layer) + acc.set(layoutInfo.layer, { + minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y, + maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height, + }) + return acc + }, new Map()) +} + +const getAlignedYPosition = ( + layoutInfo: LayoutResult['nodes'] extends Map ? T : never, + layerMap: Map, +) => { + if (layoutInfo.layer === undefined) + return layoutInfo.y + + const layerInfo = layerMap.get(layoutInfo.layer) + if (!layerInfo) + return layoutInfo.y + + return (layerInfo.minY + layerInfo.maxHeight / 2) - layoutInfo.height / 2 +} + +export const applyLayoutToNodes = ({ + nodes, + layout, + parentNodes, + childLayoutsMap, +}: { + nodes: Node[] + layout: LayoutResult + parentNodes: Node[] + childLayoutsMap: Record +}) => { + const layerMap = createLayerMap(layout) + + return produce(nodes, (draft) => { + draft.forEach((node) => { + if (!node.parentId && node.type === CUSTOM_NODE) { + const layoutInfo = layout.nodes.get(node.id) + if (!layoutInfo) + return + + node.position = { + x: layoutInfo.x, + y: getAlignedYPosition(layoutInfo, layerMap), + } + } + }) + + parentNodes.forEach((parentNode) => { + const childLayout = childLayoutsMap[parentNode.id] + if (!childLayout) + return + + draft + .filter(node => node.parentId === parentNode.id) + .forEach((childNode) => { + const layoutInfo = childLayout.nodes.get(childNode.id) + if (!layoutInfo) + return + + childNode.position = { + x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - childLayout.bounds.minX), + y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - childLayout.bounds.minY), + } + }) + }) + }) +} diff --git a/web/app/components/workflow/hooks/use-workflow-organize.ts b/web/app/components/workflow/hooks/use-workflow-organize.ts new file mode 100644 index 0000000000..284fb2261c --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-organize.ts @@ -0,0 +1,71 @@ +import { useCallback } from 'react' +import { useReactFlow, useStoreApi } from 'reactflow' +import { useWorkflowStore } from '../store' +import { + getLayoutByDagre, + getLayoutForChildNodes, +} from '../utils' +import { useNodesSyncDraft } from './use-nodes-sync-draft' +import { useNodesReadOnly } from './use-workflow' +import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' +import { + applyContainerSizeChanges, + applyLayoutToNodes, + getContainerSizeChanges, + getLayoutContainerNodes, +} from './use-workflow-organize.helpers' + +export const useWorkflowOrganize = () => { + const workflowStore = useWorkflowStore() + const store = useStoreApi() + const reactflow = useReactFlow() + const { getNodesReadOnly } = useNodesReadOnly() + const { saveStateToHistory } = useWorkflowHistory() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + + const handleLayout = useCallback(async () => { + if (getNodesReadOnly()) + return + + workflowStore.setState({ nodeAnimation: true }) + const { + getNodes, + edges, + setNodes, + } = store.getState() + const nodes = getNodes() + const parentNodes = getLayoutContainerNodes(nodes) + + const childLayoutEntries = await Promise.all( + parentNodes.map(async node => [node.id, await getLayoutForChildNodes(node.id, nodes, edges)] as const), + ) + const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => { + if (layout) + acc[nodeId] = layout + return acc + }, {} as Record>>>) + + const nodesWithUpdatedSizes = applyContainerSizeChanges( + nodes, + getContainerSizeChanges(parentNodes, childLayoutsMap), + ) + const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges) + const nextNodes = applyLayoutToNodes({ + nodes: nodesWithUpdatedSizes, + layout, + parentNodes, + childLayoutsMap, + }) + + setNodes(nextNodes) + reactflow.setViewport({ x: 0, y: 0, zoom: 0.7 }) + saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize) + setTimeout(() => { + handleSyncWorkflowDraft() + }) + }, [getNodesReadOnly, handleSyncWorkflowDraft, reactflow, saveStateToHistory, store, workflowStore]) + + return { + handleLayout, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts new file mode 100644 index 0000000000..3092a664df --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { useStore, useWorkflowStore } from '../store' +import { ControlMode } from '../types' +import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync' +import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync' +import { useSelectionInteractions } from './use-selection-interactions' +import { useNodesReadOnly } from './use-workflow' + +export const useWorkflowInteractions = () => { + const workflowStore = useWorkflowStore() + const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync() + const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() + + const handleCancelDebugAndPreviewPanel = useCallback(() => { + workflowStore.setState({ + showDebugAndPreviewPanel: false, + workflowRunningData: undefined, + }) + handleNodeCancelRunningStatus() + handleEdgeCancelRunningStatus() + }, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus]) + + return { + handleCancelDebugAndPreviewPanel, + } +} + +export const useWorkflowMoveMode = () => { + const setControlMode = useStore(s => s.setControlMode) + const { getNodesReadOnly } = useNodesReadOnly() + const { handleSelectionCancel } = useSelectionInteractions() + + const handleModePointer = useCallback(() => { + if (getNodesReadOnly()) + return + + setControlMode(ControlMode.Pointer) + }, [getNodesReadOnly, setControlMode]) + + const handleModeHand = useCallback(() => { + if (getNodesReadOnly()) + return + + setControlMode(ControlMode.Hand) + handleSelectionCancel() + }, [getNodesReadOnly, handleSelectionCancel, setControlMode]) + + return { + handleModePointer, + handleModeHand, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-update.ts b/web/app/components/workflow/hooks/use-workflow-update.ts new file mode 100644 index 0000000000..26244d2093 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-update.ts @@ -0,0 +1,37 @@ +import type { WorkflowDataUpdater } from '../types' +import { useCallback } from 'react' +import { useReactFlow } from 'reactflow' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { WORKFLOW_DATA_UPDATE } from '../constants' +import { + initialEdges, + initialNodes, +} from '../utils' + +export const useWorkflowUpdate = () => { + const reactflow = useReactFlow() + const { eventEmitter } = useEventEmitterContextContext() + + const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => { + const { + nodes, + edges, + viewport, + } = payload + + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + }, + } as never) + + if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number') + reactflow.setViewport(viewport) + }, [eventEmitter, reactflow]) + + return { + handleUpdateWorkflowCanvas, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-zoom.ts b/web/app/components/workflow/hooks/use-workflow-zoom.ts new file mode 100644 index 0000000000..f814b624c2 --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-zoom.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react' +import { useReactFlow } from 'reactflow' +import { useNodesSyncDraft } from './use-nodes-sync-draft' +import { useWorkflowReadOnly } from './use-workflow' + +export const useWorkflowZoom = () => { + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { getWorkflowReadOnly } = useWorkflowReadOnly() + const { + zoomIn, + zoomOut, + zoomTo, + fitView, + } = useReactFlow() + + const runZoomAction = useCallback((action: () => void) => { + if (getWorkflowReadOnly()) + return + + action() + handleSyncWorkflowDraft() + }, [getWorkflowReadOnly, handleSyncWorkflowDraft]) + + return { + handleFitView: useCallback(() => runZoomAction(fitView), [fitView, runZoomAction]), + handleBackToOriginalSize: useCallback(() => runZoomAction(() => zoomTo(1)), [runZoomAction, zoomTo]), + handleSizeToHalf: useCallback(() => runZoomAction(() => zoomTo(0.5)), [runZoomAction, zoomTo]), + handleZoomOut: useCallback(() => runZoomAction(zoomOut), [runZoomAction, zoomOut]), + handleZoomIn: useCallback(() => runZoomAction(zoomIn), [runZoomAction, zoomIn]), + } +} diff --git a/web/app/components/workflow/nodes/__tests__/use-config-test-utils.spec.ts b/web/app/components/workflow/nodes/__tests__/use-config-test-utils.spec.ts new file mode 100644 index 0000000000..a500223a0d --- /dev/null +++ b/web/app/components/workflow/nodes/__tests__/use-config-test-utils.spec.ts @@ -0,0 +1,24 @@ +import { createNodeCrudModuleMock, createUuidModuleMock } from './use-config-test-utils' + +describe('use-config-test-utils', () => { + it('createUuidModuleMock should return stable ids from the provided factory', () => { + const mockUuid = vi.fn(() => 'generated-id') + const moduleMock = createUuidModuleMock(mockUuid) + + expect(moduleMock.v4()).toBe('generated-id') + expect(mockUuid).toHaveBeenCalledTimes(1) + }) + + it('createNodeCrudModuleMock should expose inputs and setInputs through the default export', () => { + const setInputs = vi.fn() + const payload = { title: 'Node', type: 'code' } + const moduleMock = createNodeCrudModuleMock(setInputs) + + const result = moduleMock.default('node-1', payload) + + expect(moduleMock.__esModule).toBe(true) + expect(result.inputs).toBe(payload) + result.setInputs({ next: true }) + expect(setInputs).toHaveBeenCalledWith({ next: true }) + }) +}) diff --git a/web/app/components/workflow/nodes/__tests__/use-config-test-utils.ts b/web/app/components/workflow/nodes/__tests__/use-config-test-utils.ts new file mode 100644 index 0000000000..d7714fdac1 --- /dev/null +++ b/web/app/components/workflow/nodes/__tests__/use-config-test-utils.ts @@ -0,0 +1,13 @@ +type SetInputsMock = (value: unknown) => void + +export const createUuidModuleMock = (getId: () => string) => ({ + v4: () => getId(), +}) + +export const createNodeCrudModuleMock = (setInputs: SetInputsMock) => ({ + __esModule: true as const, + default: (_id: string, data: T) => ({ + inputs: data, + setInputs, + }), +}) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts new file mode 100644 index 0000000000..427fcd32c8 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-config.helpers.spec.ts @@ -0,0 +1,68 @@ +import type { AssignerNodeType } from '../types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { AssignerNodeInputType, WriteMode } from '../types' +import { + canAssignToVar, + canAssignVar, + ensureAssignerVersion, + filterVarByType, + normalizeAssignedVarType, + updateOperationItems, +} from '../use-config.helpers' + +const createInputs = (version: AssignerNodeType['version'] = '1'): AssignerNodeType => ({ + title: 'Assigner', + desc: '', + type: BlockEnum.Assigner, + version, + items: [{ + variable_selector: ['conversation', 'count'], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: ['node-1', 'value'], + }], +}) + +describe('assigner use-config helpers', () => { + it('filters vars and selectors by supported targets', () => { + expect(filterVarByType(VarType.any)({ type: VarType.string } as never)).toBe(true) + expect(filterVarByType(VarType.number)({ type: VarType.any } as never)).toBe(true) + expect(filterVarByType(VarType.number)({ type: VarType.string } as never)).toBe(false) + expect(canAssignVar({} as never, ['conversation', 'total'])).toBe(true) + expect(canAssignVar({} as never, ['sys', 'total'])).toBe(false) + }) + + it('normalizes assigned variable types for append and passthrough write modes', () => { + expect(normalizeAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string) + expect(normalizeAssignedVarType(VarType.arrayNumber, WriteMode.append)).toBe(VarType.number) + expect(normalizeAssignedVarType(VarType.arrayObject, WriteMode.append)).toBe(VarType.object) + expect(normalizeAssignedVarType(VarType.number, WriteMode.append)).toBe(VarType.string) + expect(normalizeAssignedVarType(VarType.number, WriteMode.increment)).toBe(VarType.number) + expect(normalizeAssignedVarType(VarType.string, WriteMode.clear)).toBe(VarType.string) + }) + + it('validates assignment targets for append, arithmetic and fallback modes', () => { + expect(canAssignToVar({ type: VarType.number } as never, VarType.number, WriteMode.multiply)).toBe(true) + expect(canAssignToVar({ type: VarType.string } as never, VarType.number, WriteMode.multiply)).toBe(false) + expect(canAssignToVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true) + expect(canAssignToVar({ type: VarType.number } as never, VarType.arrayNumber, WriteMode.append)).toBe(true) + expect(canAssignToVar({ type: VarType.object } as never, VarType.arrayObject, WriteMode.append)).toBe(true) + expect(canAssignToVar({ type: VarType.boolean } as never, VarType.arrayString, WriteMode.append)).toBe(false) + expect(canAssignToVar({ type: VarType.string } as never, VarType.string, WriteMode.set)).toBe(true) + }) + + it('ensures version 2 and replaces operation items immutably', () => { + const legacyInputs = createInputs('1') + const nextItems = [{ + variable_selector: ['conversation', 'total'], + input_type: AssignerNodeInputType.constant, + operation: WriteMode.clear, + value: '0', + }] + + expect(ensureAssignerVersion(legacyInputs).version).toBe('2') + expect(ensureAssignerVersion(createInputs('2')).version).toBe('2') + expect(updateOperationItems(legacyInputs, nextItems).items).toEqual(nextItems) + expect(legacyInputs.items).toHaveLength(1) + }) +}) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..60bd1dd0f7 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx @@ -0,0 +1,98 @@ +import type { AssignerNodeOperation, AssignerNodeType } from '../types' +import { renderHook } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils' +import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types' +import useConfig from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockGetAvailableVars = vi.hoisted(() => vi.fn()) +const mockGetCurrentVariableType = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useIsChatMode: () => false, + useWorkflow: () => ({ + getBeforeNodesInSameBranchIncludeParent: () => [ + { id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } }, + ], + }), + useWorkflowVariables: () => ({ + getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('../hooks', () => ({ + useGetAvailableVars: () => mockGetAvailableVars, +})) + +vi.mock('reactflow', async () => { + const actual = await vi.importActual('reactflow') + return { + ...actual, + useStoreApi: () => ({ + getState: () => ({ + getNodes: () => [ + { id: 'assigner-node', parentId: 'iteration-parent' }, + { id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } }, + ], + }), + }), + } +}) + +const createOperation = (overrides: Partial = {}): AssignerNodeOperation => ({ + variable_selector: ['conversation', 'count'], + input_type: AssignerNodeInputType.variable, + operation: WriteMode.overwrite, + value: ['node-2', 'result'], + ...overrides, +}) + +const createPayload = (overrides: Partial = {}): AssignerNodeType => ({ + title: 'Assigner', + desc: '', + type: BlockEnum.Assigner, + version: '1', + items: [createOperation()], + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetCurrentVariableType.mockReturnValue(VarType.arrayString) + mockGetAvailableVars.mockReturnValue([]) + }) + + it('should normalize legacy payloads, expose write mode groups and derive assigned variable types', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + expect(result.current.readOnly).toBe(false) + expect(result.current.writeModeTypes).toEqual([WriteMode.overwrite, WriteMode.clear, WriteMode.set]) + expect(result.current.writeModeTypesNum).toEqual(writeModeTypesNum) + expect(result.current.getAssignedVarType(['conversation', 'count'])).toBe(VarType.arrayString) + expect(result.current.getToAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string) + expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true) + }) + + it('should update operation lists with version 2 payloads and apply assignment filters', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + const nextItems = [createOperation({ operation: WriteMode.append })] + + result.current.handleOperationListChanges(nextItems) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + version: '2', + items: nextItems, + })) + expect(result.current.filterAssignedVar({ isLoopVariable: true } as never, ['node', 'value'])).toBe(true) + expect(result.current.filterAssignedVar({} as never, ['conversation', 'name'])).toBe(true) + expect(result.current.filterToAssignedVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true) + expect(result.current.filterToAssignedVar({ type: VarType.number } as never, VarType.arrayString, WriteMode.append)).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/assigner/use-config.helpers.ts b/web/app/components/workflow/nodes/assigner/use-config.helpers.ts new file mode 100644 index 0000000000..1bd3ac6d35 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/use-config.helpers.ts @@ -0,0 +1,90 @@ +import type { ValueSelector, Var } from '../../types' +import type { AssignerNodeOperation, AssignerNodeType } from './types' +import { produce } from 'immer' +import { VarType } from '../../types' +import { WriteMode } from './types' + +export const filterVarByType = (varType: VarType) => { + return (variable: Var) => { + if (varType === VarType.any || variable.type === VarType.any) + return true + + return variable.type === varType + } +} + +export const normalizeAssignedVarType = (assignedVarType: VarType, writeMode: WriteMode) => { + if ( + writeMode === WriteMode.overwrite + || writeMode === WriteMode.increment + || writeMode === WriteMode.decrement + || writeMode === WriteMode.multiply + || writeMode === WriteMode.divide + || writeMode === WriteMode.extend + ) { + return assignedVarType + } + + if (writeMode === WriteMode.append) { + switch (assignedVarType) { + case VarType.arrayString: + return VarType.string + case VarType.arrayNumber: + return VarType.number + case VarType.arrayObject: + return VarType.object + default: + return VarType.string + } + } + + return VarType.string +} + +export const canAssignVar = (_varPayload: Var, selector: ValueSelector) => { + return selector.join('.').startsWith('conversation') +} + +export const canAssignToVar = ( + varPayload: Var, + assignedVarType: VarType, + writeMode: WriteMode, +) => { + if ( + writeMode === WriteMode.overwrite + || writeMode === WriteMode.extend + || writeMode === WriteMode.increment + || writeMode === WriteMode.decrement + || writeMode === WriteMode.multiply + || writeMode === WriteMode.divide + ) { + return varPayload.type === assignedVarType + } + + if (writeMode === WriteMode.append) { + switch (assignedVarType) { + case VarType.arrayString: + return varPayload.type === VarType.string + case VarType.arrayNumber: + return varPayload.type === VarType.number + case VarType.arrayObject: + return varPayload.type === VarType.object + default: + return false + } + } + + return true +} + +export const ensureAssignerVersion = (newInputs: AssignerNodeType) => produce(newInputs, (draft) => { + if (draft.version !== '2') + draft.version = '2' +}) + +export const updateOperationItems = ( + inputs: AssignerNodeType, + items: AssignerNodeOperation[], +) => produce(inputs, (draft) => { + draft.items = [...items] +}) diff --git a/web/app/components/workflow/nodes/assigner/use-config.ts b/web/app/components/workflow/nodes/assigner/use-config.ts index b9f34c4a9c..e15f319495 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.ts +++ b/web/app/components/workflow/nodes/assigner/use-config.ts @@ -1,6 +1,5 @@ import type { ValueSelector, Var } from '../../types' import type { AssignerNodeOperation, AssignerNodeType } from './types' -import { produce } from 'immer' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' import { @@ -10,9 +9,16 @@ import { useWorkflowVariables, } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { VarType } from '../../types' import { useGetAvailableVars } from './hooks' import { WriteMode, writeModeTypesNum } from './types' +import { + canAssignToVar, + canAssignVar, + ensureAssignerVersion, + filterVarByType, + normalizeAssignedVarType, + updateOperationItems, +} from './use-config.helpers' import { convertV1ToV2 } from './utils' const useConfig = (id: string, rawPayload: AssignerNodeType) => { @@ -20,15 +26,6 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() const isChatMode = useIsChatMode() const getAvailableVars = useGetAvailableVars() - const filterVar = (varType: VarType) => { - return (v: Var) => { - if (varType === VarType.any) - return true - if (v.type === VarType.any) - return true - return v.type === varType - } - } const store = useStoreApi() const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow() @@ -44,11 +41,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { }, [getBeforeNodesInSameBranchIncludeParent, id]) const { inputs, setInputs } = useNodeCrud(id, payload) const newSetInputs = useCallback((newInputs: AssignerNodeType) => { - const finalInputs = produce(newInputs, (draft) => { - if (draft.version !== '2') - draft.version = '2' - }) - setInputs(finalInputs) + setInputs(ensureAssignerVersion(newInputs)) }, [setInputs]) const { getCurrentVariableType } = useWorkflowVariables() @@ -63,56 +56,21 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { }, [getCurrentVariableType, isInIteration, iterationNode, availableNodes, isChatMode]) const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => { - const newInputs = produce(inputs, (draft) => { - draft.items = [...items] - }) - newSetInputs(newInputs) + newSetInputs(updateOperationItems(inputs, items)) }, [inputs, newSetInputs]) const writeModeTypesArr = [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend, WriteMode.removeFirst, WriteMode.removeLast] const writeModeTypes = [WriteMode.overwrite, WriteMode.clear, WriteMode.set] - const getToAssignedVarType = useCallback((assignedVarType: VarType, write_mode: WriteMode) => { - if (write_mode === WriteMode.overwrite || write_mode === WriteMode.increment || write_mode === WriteMode.decrement - || write_mode === WriteMode.multiply || write_mode === WriteMode.divide || write_mode === WriteMode.extend) { - return assignedVarType - } - if (write_mode === WriteMode.append) { - if (assignedVarType === VarType.arrayString) - return VarType.string - if (assignedVarType === VarType.arrayNumber) - return VarType.number - if (assignedVarType === VarType.arrayObject) - return VarType.object - } - return VarType.string - }, []) + const getToAssignedVarType = useCallback(normalizeAssignedVarType, []) const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => { if (varPayload.isLoopVariable) return true - return selector.join('.').startsWith('conversation') + return canAssignVar(varPayload, selector) }, []) - const filterToAssignedVar = useCallback((varPayload: Var, assignedVarType: VarType, write_mode: WriteMode) => { - if (write_mode === WriteMode.overwrite || write_mode === WriteMode.extend || write_mode === WriteMode.increment - || write_mode === WriteMode.decrement || write_mode === WriteMode.multiply || write_mode === WriteMode.divide) { - return varPayload.type === assignedVarType - } - else if (write_mode === WriteMode.append) { - switch (assignedVarType) { - case VarType.arrayString: - return varPayload.type === VarType.string - case VarType.arrayNumber: - return varPayload.type === VarType.number - case VarType.arrayObject: - return varPayload.type === VarType.object - default: - return false - } - } - return true - }, []) + const filterToAssignedVar = useCallback(canAssignToVar, []) return { readOnly, @@ -126,7 +84,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => { filterAssignedVar, filterToAssignedVar, getAvailableVars, - filterVar, + filterVar: filterVarByType, } } diff --git a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx new file mode 100644 index 0000000000..1d11b9b882 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx @@ -0,0 +1,165 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BodyPayloadValueType, BodyType } from '../../types' +import CurlPanel from '../curl-panel' +import * as curlParser from '../curl-parser' + +const { + mockHandleNodeSelect, + mockNotify, +} = vi.hoisted(() => ({ + mockHandleNodeSelect: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: mockNotify, + }, +})) + +describe('curl-panel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('parseCurl', () => { + it('should parse method, headers, json body, and query params from a valid curl command', () => { + const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2') + + expect(error).toBeNull() + expect(node).toMatchObject({ + method: 'post', + url: 'https://example.com/users', + headers: 'Authorization: Bearer token', + params: 'page: 1\nsize: 2', + }) + }) + + it('should return an error for invalid curl input', () => { + expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command') + }) + + it('should parse form data and attach typed content headers', () => { + const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload') + + expect(error).toBeNull() + expect(node).toMatchObject({ + method: 'post', + url: 'https://example.com/upload', + headers: 'Content-Type: text/plain', + body: { + type: BodyType.formData, + data: 'file:@report.txt\nname:openai', + }, + }) + }) + + it('should parse raw payloads and preserve equals signs in the body value', () => { + const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw') + + expect(error).toBeNull() + expect(node?.body).toEqual({ + type: BodyType.rawText, + data: [{ + type: BodyPayloadValueType.text, + value: 'token=abc=123', + }], + }) + }) + + it.each([ + ['curl -X', 'Missing HTTP method after -X or --request.'], + ['curl --header', 'Missing header value after -H or --header.'], + ['curl --data-raw', 'Missing data value after -d, --data, --data-raw, or --data-binary.'], + ['curl --form', 'Missing form data after -F or --form.'], + ['curl --json', 'Missing JSON data after --json.'], + ['curl --form "=broken" https://example.com/upload', 'Invalid form data format.'], + ['curl -H "Accept: application/json"', 'Missing URL or url not start with http.'], + ])('should return a descriptive error for %s', (command, expectedError) => { + expect(curlParser.parseCurl(command)).toEqual({ + node: null, + error: expectedError, + }) + }) + }) + + describe('component actions', () => { + it('should import a parsed curl node and reselect the node after saving', async () => { + const user = userEvent.setup() + const onHide = vi.fn() + const handleCurlImport = vi.fn() + + render( + , + ) + + await user.type(screen.getByRole('textbox'), 'curl https://example.com') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onHide).toHaveBeenCalledTimes(1) + expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({ + method: 'get', + url: 'https://example.com', + })) + expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true) + }) + + it('should notify the user when the curl command is invalid', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.type(screen.getByRole('textbox'), 'invalid') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should keep the panel open when parsing returns no node and no error', async () => { + const user = userEvent.setup() + const onHide = vi.fn() + const handleCurlImport = vi.fn() + vi.spyOn(curlParser, 'parseCurl').mockReturnValueOnce({ + node: null, + error: null, + }) + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onHide).not.toHaveBeenCalled() + expect(handleCurlImport).not.toHaveBeenCalled() + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index 6c809c310f..7b6a26cc29 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import Toast from '@/app/components/base/toast' import { useNodesInteractions } from '@/app/components/workflow/hooks' -import { BodyPayloadValueType, BodyType, Method } from '../types' +import { parseCurl } from './curl-parser' type Props = { nodeId: string @@ -18,104 +18,6 @@ type Props = { handleCurlImport: (node: HttpNodeType) => void } -const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => { - if (!curlCommand.trim().toLowerCase().startsWith('curl')) - return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } - - const node: Partial = { - title: 'HTTP Request', - desc: 'Imported from cURL', - method: undefined, - url: '', - headers: '', - params: '', - body: { type: BodyType.none, data: '' }, - } - const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || [] - let hasData = false - - for (let i = 1; i < args.length; i++) { - const arg = args[i].replace(/^['"]|['"]$/g, '') - switch (arg) { - case '-X': - case '--request': - if (i + 1 >= args.length) - return { node: null, error: 'Missing HTTP method after -X or --request.' } - node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get - hasData = true - break - case '-H': - case '--header': - if (i + 1 >= args.length) - return { node: null, error: 'Missing header value after -H or --header.' } - node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '') - break - case '-d': - case '--data': - case '--data-raw': - case '--data-binary': { - if (i + 1 >= args.length) - return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' } - const bodyPayload = [{ - type: BodyPayloadValueType.text, - value: args[++i].replace(/^['"]|['"]$/g, ''), - }] - node.body = { type: BodyType.rawText, data: bodyPayload } - break - } - case '-F': - case '--form': { - if (i + 1 >= args.length) - return { node: null, error: 'Missing form data after -F or --form.' } - if (node.body?.type !== BodyType.formData) - node.body = { type: BodyType.formData, data: '' } - const formData = args[++i].replace(/^['"]|['"]$/g, '') - const [key, ...valueParts] = formData.split('=') - if (!key) - return { node: null, error: 'Invalid form data format.' } - let value = valueParts.join('=') - - // To support command like `curl -F "file=@/path/to/file;type=application/zip"` - // the `;type=application/zip` should translate to `Content-Type: application/zip` - const typeRegex = /^(.+?);type=(.+)$/ - const typeMatch = typeRegex.exec(value) - if (typeMatch) { - const [, actualValue, mimeType] = typeMatch - value = actualValue - node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}` - } - - node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}` - break - } - case '--json': - if (i + 1 >= args.length) - return { node: null, error: 'Missing JSON data after --json.' } - node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') } - break - default: - if (arg.startsWith('http') && !node.url) - node.url = arg - break - } - } - - // Determine final method - node.method = node.method || (hasData ? Method.post : Method.get) - - if (!node.url) - return { node: null, error: 'Missing URL or url not start with http.' } - - // Extract query params from URL - const urlParts = node.url?.split('?') || [] - if (urlParts.length > 1) { - node.url = urlParts[0] - node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ') - } - - return { node: node as HttpNodeType, error: null } -} - const CurlPanel: FC = ({ nodeId, isShow, onHide, handleCurlImport }) => { const [inputString, setInputString] = useState('') const { handleNodeSelect } = useNodesInteractions() diff --git a/web/app/components/workflow/nodes/http/components/curl-parser.ts b/web/app/components/workflow/nodes/http/components/curl-parser.ts new file mode 100644 index 0000000000..ba9319cbf0 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/curl-parser.ts @@ -0,0 +1,171 @@ +import type { HttpNodeType } from '../types' +import { BodyPayloadValueType, BodyType, Method } from '../types' + +const METHOD_ARG_FLAGS = new Set(['-X', '--request']) +const HEADER_ARG_FLAGS = new Set(['-H', '--header']) +const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary']) +const FORM_ARG_FLAGS = new Set(['-F', '--form']) + +type ParseStepResult = { + error: string | null + nextIndex: number + hasData?: boolean +} + +const stripWrappedQuotes = (value: string) => { + return value.replace(/^['"]|['"]$/g, '') +} + +const parseCurlArgs = (curlCommand: string) => { + return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || [] +} + +const buildDefaultNode = (): Partial => ({ + title: 'HTTP Request', + desc: 'Imported from cURL', + method: undefined, + url: '', + headers: '', + params: '', + body: { type: BodyType.none, data: '' }, +}) + +const extractUrlParams = (url: string) => { + const urlParts = url.split('?') + if (urlParts.length <= 1) + return { url, params: '' } + + return { + url: urlParts[0], + params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '), + } +} + +const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => { + if (index + 1 >= args.length) + return { value: null, error } + + return { + value: stripWrappedQuotes(args[index + 1]), + error: null, + } +} + +const applyMethodArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index, hasData: false } + + node.method = (nextArg.value.toLowerCase() as Method) || Method.get + return { error: null, nextIndex: index + 1, hasData: true } +} + +const applyHeaderArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.headers += `${node.headers ? '\n' : ''}${nextArg.value}` + return { error: null, nextIndex: index + 1 } +} + +const applyDataArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.body = { + type: BodyType.rawText, + data: [{ type: BodyPayloadValueType.text, value: nextArg.value }], + } + return { error: null, nextIndex: index + 1 } +} + +const applyFormArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + if (node.body?.type !== BodyType.formData) + node.body = { type: BodyType.formData, data: '' } + + const [key, ...valueParts] = nextArg.value.split('=') + if (!key) + return { error: 'Invalid form data format.', nextIndex: index } + + let value = valueParts.join('=') + const typeMatch = /^(.+?);type=(.+)$/.exec(value) + if (typeMatch) { + const [, actualValue, mimeType] = typeMatch + value = actualValue + node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}` + } + + node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}` + return { error: null, nextIndex: index + 1 } +} + +const applyJsonArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing JSON data after --json.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.body = { type: BodyType.json, data: nextArg.value } + return { error: null, nextIndex: index + 1 } +} + +const handleCurlArg = ( + arg: string, + node: Partial, + args: string[], + index: number, +): ParseStepResult => { + if (METHOD_ARG_FLAGS.has(arg)) + return applyMethodArg(node, args, index) + + if (HEADER_ARG_FLAGS.has(arg)) + return applyHeaderArg(node, args, index) + + if (DATA_ARG_FLAGS.has(arg)) + return applyDataArg(node, args, index) + + if (FORM_ARG_FLAGS.has(arg)) + return applyFormArg(node, args, index) + + if (arg === '--json') + return applyJsonArg(node, args, index) + + if (arg.startsWith('http') && !node.url) + node.url = arg + + return { error: null, nextIndex: index, hasData: false } +} + +export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => { + if (!curlCommand.trim().toLowerCase().startsWith('curl')) + return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } + + const node = buildDefaultNode() + const args = parseCurlArgs(curlCommand) + let hasData = false + + for (let i = 1; i < args.length; i++) { + const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i) + if (result.error) + return { node: null, error: result.error } + + hasData ||= Boolean(result.hasData) + i = result.nextIndex + } + + node.method = node.method || (hasData ? Method.post : Method.get) + + if (!node.url) + return { node: null, error: 'Missing URL or url not start with http.' } + + const parsedUrl = extractUrlParams(node.url) + node.url = parsedUrl.url + node.params = parsedUrl.params + + return { node: node as HttpNodeType, error: null } +} diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx new file mode 100644 index 0000000000..1847ceaa9b --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react' +import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown' + +describe('variable-in-markdown', () => { + describe('rehypeVariable', () => { + it('should replace variable tokens with variable elements and preserve surrounding text', () => { + const tree = { + children: [ + { + type: 'text', + value: 'Hello {{#node.field#}} world', + }, + ], + } + + rehypeVariable()(tree) + + expect(tree.children).toEqual([ + { type: 'text', value: 'Hello ' }, + { + type: 'element', + tagName: 'variable', + properties: { dataPath: '{{#node.field#}}' }, + children: [], + }, + { type: 'text', value: ' world' }, + ]) + }) + + it('should ignore note tokens while processing variable nodes', () => { + const tree = { + children: [ + { + type: 'text', + value: 'Hello {{#$node.field#}} world', + }, + ], + } + + rehypeVariable()(tree) + + expect(tree.children).toEqual([ + { + type: 'text', + value: 'Hello {{#$node.field#}} world', + }, + ]) + }) + }) + + describe('rehypeNotes', () => { + it('should replace note tokens with section nodes and update the parent tag name', () => { + const tree = { + tagName: 'p', + children: [ + { + type: 'text', + value: 'See {{#$node.title#}} please', + }, + ], + } + + rehypeNotes()(tree) + + expect(tree.tagName).toBe('div') + expect(tree.children).toEqual([ + { type: 'text', value: 'See ' }, + { + type: 'element', + tagName: 'section', + properties: { dataName: 'title' }, + children: [], + }, + { type: 'text', value: ' please' }, + ]) + }) + }) + + describe('rendering', () => { + it('should format variable paths for display', () => { + render() + + expect(screen.getByText('{{node/field}}')).toBeInTheDocument() + }) + + it('should render note values and replace node ids with labels for variable defaults', () => { + const { rerender } = render( + nodeId === 'node-1' ? 'Start Node' : nodeId} + />, + ) + + expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument() + + rerender( + nodeId} + />, + ) + + expect(screen.getByText('Plain value')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx index 0da56e3233..a09dc287b4 100644 --- a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx @@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types' const variableRegex = /\{\{#(.+?)#\}\}/g const noteRegex = /\{\{#\$(.+?)#\}\}/g -export function rehypeVariable() { - return (tree: any) => { - const iterate = (node: any, index: number, parent: any) => { - const value = node.value +type MarkdownNode = { + type?: string + value?: string + tagName?: string + properties?: Record + children?: MarkdownNode[] +} +type SplitMatchResult = { + tagName: string + properties: Record +} + +const splitTextNode = ( + value: string, + regex: RegExp, + createMatchNode: (match: RegExpExecArray) => SplitMatchResult, +) => { + const parts: MarkdownNode[] = [] + let lastIndex = 0 + let match = regex.exec(value) + + while (match !== null) { + if (match.index > lastIndex) + parts.push({ type: 'text', value: value.slice(lastIndex, match.index) }) + + const { tagName, properties } = createMatchNode(match) + parts.push({ + type: 'element', + tagName, + properties, + children: [], + }) + + lastIndex = match.index + match[0].length + match = regex.exec(value) + } + + if (!parts.length) + return parts + + if (lastIndex < value.length) + parts.push({ type: 'text', value: value.slice(lastIndex) }) + + return parts +} + +const visitTextNodes = ( + node: MarkdownNode, + transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null, +) => { + if (!node.children) + return + + let index = 0 + while (index < node.children.length) { + const child = node.children[index] + if (child.type === 'text' && typeof child.value === 'string') { + const nextNodes = transform(child.value, node) + if (nextNodes) { + node.children.splice(index, 1, ...nextNodes) + index += nextNodes.length + continue + } + } + + visitTextNodes(child, transform) + index++ + } +} + +const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => { + return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => { + return `#${nodeName(nodeId)}${separator}` + }) +} + +const formatVariablePath = (path: string) => { + return path.replaceAll('.', '/') + .replace('{{#', '{{') + .replace('#}}', '}}') +} + +export function rehypeVariable() { + return (tree: MarkdownNode) => { + visitTextNodes(tree, (value) => { variableRegex.lastIndex = 0 noteRegex.lastIndex = 0 - if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) { - let m: RegExpExecArray | null - let last = 0 - const parts: any[] = [] - variableRegex.lastIndex = 0 - m = variableRegex.exec(value) - while (m !== null) { - if (m.index > last) - parts.push({ type: 'text', value: value.slice(last, m.index) }) + if (!variableRegex.test(value) || noteRegex.test(value)) + return null - parts.push({ - type: 'element', - tagName: 'variable', - properties: { dataPath: m[0].trim() }, - children: [], - }) - - last = m.index + m[0].length - m = variableRegex.exec(value) - } - - if (parts.length) { - if (last < value.length) - parts.push({ type: 'text', value: value.slice(last) }) - - parent.children.splice(index, 1, ...parts) - } - } - if (node.children) { - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < node.children.length) { - iterate(node.children[i], i, node) - i++ - } - } - } - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < tree.children.length) { - iterate(tree.children[i], i, tree) - i++ - } + variableRegex.lastIndex = 0 + return splitTextNode(value, variableRegex, match => ({ + tagName: 'variable', + properties: { dataPath: match[0].trim() }, + })) + }) } } export function rehypeNotes() { - return (tree: any) => { - const iterate = (node: any, index: number, parent: any) => { - const value = node.value + return (tree: MarkdownNode) => { + visitTextNodes(tree, (value, parent) => { + noteRegex.lastIndex = 0 + if (!noteRegex.test(value)) + return null noteRegex.lastIndex = 0 - if (node.type === 'text' && noteRegex.test(value)) { - let m: RegExpExecArray | null - let last = 0 - const parts: any[] = [] - noteRegex.lastIndex = 0 - m = noteRegex.exec(value) - while (m !== null) { - if (m.index > last) - parts.push({ type: 'text', value: value.slice(last, m.index) }) - - const name = m[0].split('.').slice(-1)[0].replace('#}}', '') - parts.push({ - type: 'element', - tagName: 'section', - properties: { dataName: name }, - children: [], - }) - - last = m.index + m[0].length - m = noteRegex.exec(value) + parent.tagName = 'div' + return splitTextNode(value, noteRegex, (match) => { + const name = match[0].split('.').slice(-1)[0].replace('#}}', '') + return { + tagName: 'section', + properties: { dataName: name }, } - - if (parts.length) { - if (last < value.length) - parts.push({ type: 'text', value: value.slice(last) }) - - parent.children.splice(index, 1, ...parts) - parent.tagName = 'div' // h2 can not in p. In note content include the h2 - } - } - if (node.children) { - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < node.children.length) { - iterate(node.children[i], i, node) - i++ - } - } - } - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < tree.children.length) { - iterate(tree.children[i], i, tree) - i++ - } + }) + }) } } export const Variable: React.FC<{ path: string }> = ({ path }) => { return ( - { - path.replaceAll('.', '/') - .replace('{{#', '{{') - .replace('#}}', '}}') - } + {formatVariablePath(path)} ) } @@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => { export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => { const isVariable = defaultInput.type === 'variable' const path = `{{#${defaultInput.selector.join('.')}#}}` - let newPath = path - if (path) { - newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => { - return `#${nodeName(nodeId)}${sep}` - }) - } + const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path return (
{isVariable ? : {defaultInput.value}} diff --git a/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts new file mode 100644 index 0000000000..4966bf1e70 --- /dev/null +++ b/web/app/components/workflow/nodes/if-else/__tests__/use-config.helpers.spec.ts @@ -0,0 +1,172 @@ +import type { IfElseNodeType } from '../types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { LogicalOperator } from '../types' +import { + addCase, + addCondition, + addSubVariableCondition, + filterAllVars, + filterNumberVars, + getVarsIsVarFileAttribute, + removeCase, + removeCondition, + removeSubVariableCondition, + sortCases, + toggleConditionLogicalOperator, + toggleSubVariableConditionLogicalOperator, + updateCondition, + updateSubVariableCondition, +} from '../use-config.helpers' + +type TestIfElseInputs = ReturnType + +const createInputs = (): IfElseNodeType => ({ + title: 'If/Else', + desc: '', + type: BlockEnum.IfElse, + cases: [{ + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node', 'value'], + comparison_operator: 'contains', + value: '', + }], + }], + _targetBranches: [ + { id: 'case-1', name: 'Case 1' }, + { id: 'false', name: 'Else' }, + ], +} as unknown as IfElseNodeType) + +describe('if-else use-config helpers', () => { + it('filters vars and derives file attribute flags', () => { + expect(filterAllVars()).toBe(true) + expect(filterNumberVars({ type: VarType.number } as never)).toBe(true) + expect(filterNumberVars({ type: VarType.string } as never)).toBe(false) + expect(getVarsIsVarFileAttribute(createInputs().cases, selector => selector[1] === 'value')).toEqual({ + 'condition-1': true, + }) + }) + + it('adds, removes and sorts cases while keeping target branches aligned', () => { + const added = addCase(createInputs()) + expect(added.cases).toHaveLength(2) + expect(added._targetBranches?.map(branch => branch.id)).toContain('false') + + const removed = removeCase(added, 'case-1') + expect(removed.cases?.some(item => item.case_id === 'case-1')).toBe(false) + + const sorted = sortCases(createInputs(), [ + { id: 'display-2', case_id: 'case-2', logical_operator: LogicalOperator.or, conditions: [] }, + { id: 'display-1', case_id: 'case-1', logical_operator: LogicalOperator.and, conditions: [] }, + ] as unknown as Parameters[1]) + expect(sorted.cases?.map(item => item.case_id)).toEqual(['case-2', 'case-1']) + expect(sorted._targetBranches?.map(branch => branch.id)).toEqual(['case-2', 'case-1', 'false']) + }) + + it('adds, updates, toggles and removes conditions and sub-conditions', () => { + const withCondition = addCondition({ + inputs: createInputs(), + caseId: 'case-1', + valueSelector: ['node', 'flag'], + variable: { type: VarType.boolean } as never, + isVarFileAttribute: false, + }) + expect(withCondition.cases?.[0]?.conditions).toHaveLength(2) + expect(withCondition.cases?.[0]?.conditions[1]).toEqual(expect.objectContaining({ + value: false, + variable_selector: ['node', 'flag'], + })) + + const updatedCondition = updateCondition(withCondition, 'case-1', 'condition-1', { + id: 'condition-1', + value: 'next', + comparison_operator: '=', + } as Parameters[3]) + expect(updatedCondition.cases?.[0]?.conditions[0]).toEqual(expect.objectContaining({ + value: 'next', + comparison_operator: '=', + })) + + const toggled = toggleConditionLogicalOperator(updatedCondition, 'case-1') + expect(toggled.cases?.[0]?.logical_operator).toBe(LogicalOperator.or) + + const withSubCondition = addSubVariableCondition(toggled, 'case-1', 'condition-1', 'name') + expect(withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ + key: 'name', + value: '', + })) + + const firstSubConditionId = withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]?.id + expect(firstSubConditionId).toBeTruthy() + const updatedSubCondition = updateSubVariableCondition( + withSubCondition, + 'case-1', + 'condition-1', + firstSubConditionId!, + { key: 'size', comparison_operator: '>', value: '10' } as TestIfElseInputs['cases'][number]['conditions'][number], + ) + expect(updatedSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ + key: 'size', + value: '10', + })) + + const toggledSub = toggleSubVariableConditionLogicalOperator(updatedSubCondition, 'case-1', 'condition-1') + expect(toggledSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.logical_operator).toBe(LogicalOperator.or) + + const removedSub = removeSubVariableCondition( + toggledSub, + 'case-1', + 'condition-1', + firstSubConditionId!, + ) + expect(removedSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions).toEqual([]) + + const removedCondition = removeCondition(removedSub, 'case-1', 'condition-1') + expect(removedCondition.cases?.[0]?.conditions.some(item => item.id === 'condition-1')).toBe(false) + }) + + it('keeps inputs unchanged when guard branches short-circuit helper updates', () => { + const unchangedWithoutCases = addCase({ + ...createInputs(), + cases: undefined, + } as unknown as IfElseNodeType) + expect(unchangedWithoutCases.cases).toBeUndefined() + + const withoutTargetBranches = addCase({ + ...createInputs(), + _targetBranches: undefined, + }) + expect(withoutTargetBranches._targetBranches).toBeUndefined() + + const withoutElseBranch = addCase({ + ...createInputs(), + _targetBranches: [{ id: 'case-1', name: 'Case 1' }], + }) + expect(withoutElseBranch._targetBranches).toEqual([{ id: 'case-1', name: 'Case 1' }]) + + const unchangedWhenConditionMissing = addSubVariableCondition(createInputs(), 'case-1', 'missing-condition', 'name') + expect(unchangedWhenConditionMissing).toEqual(createInputs()) + + const unchangedWhenSubConditionMissing = removeSubVariableCondition(createInputs(), 'case-1', 'condition-1', 'missing-sub') + expect(unchangedWhenSubConditionMissing).toEqual(createInputs()) + + const unchangedWhenCaseIsMissingForCondition = addCondition({ + inputs: createInputs(), + caseId: 'missing-case', + valueSelector: ['node', 'value'], + variable: { type: VarType.string } as never, + isVarFileAttribute: false, + }) + expect(unchangedWhenCaseIsMissingForCondition).toEqual(createInputs()) + + const unchangedWhenCaseMissing = toggleConditionLogicalOperator(createInputs(), 'missing-case') + expect(unchangedWhenCaseMissing).toEqual(createInputs()) + + const unchangedWhenSubVariableGroupMissing = toggleSubVariableConditionLogicalOperator(createInputs(), 'case-1', 'condition-1') + expect(unchangedWhenSubVariableGroupMissing).toEqual(createInputs()) + }) +}) diff --git a/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..e67dbdfc74 --- /dev/null +++ b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx @@ -0,0 +1,266 @@ +import type { IfElseNodeType } from '../types' +import { renderHook } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { + createNodeCrudModuleMock, + createUuidModuleMock, +} from '../../__tests__/use-config-test-utils' +import { ComparisonOperator, LogicalOperator } from '../types' +import useConfig from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockHandleEdgeDeleteByDeleteBranch = vi.hoisted(() => vi.fn()) +const mockUpdateNodeInternals = vi.hoisted(() => vi.fn()) +const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn()) +const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id')) + +vi.mock('uuid', () => ({ + ...createUuidModuleMock(mockUuid), +})) + +vi.mock('reactflow', async () => { + const actual = await vi.importActual('reactflow') + return { + ...actual, + useUpdateNodeInternals: () => mockUpdateNodeInternals, + } +}) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useEdgesInteractions: () => ({ + handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => mockHandleEdgeDeleteByDeleteBranch(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + __esModule: true, + default: (_id: string, { filterVar }: { filterVar: (value: { type: VarType }) => boolean }) => ({ + availableVars: filterVar({ type: VarType.number }) + ? [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'score', type: VarType.number }] }] + : [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'answer', type: VarType.string }] }], + availableNodesWithParent: [], + }), +})) + +vi.mock('../use-is-var-file-attribute', () => ({ + __esModule: true, + default: () => ({ + getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args), + }), +})) + +const createPayload = (overrides: Partial = {}): IfElseNodeType => ({ + title: 'If Else', + desc: '', + type: BlockEnum.IfElse, + isInIteration: false, + isInLoop: false, + cases: [{ + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node-1', 'answer'], + comparison_operator: ComparisonOperator.contains, + value: 'hello', + }], + }], + _targetBranches: [ + { id: 'case-1', name: 'IF' }, + { id: 'false', name: 'ELSE' }, + ], + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIsVarFileAttribute.mockReturnValue(false) + }) + + it('should expose derived vars and file-attribute flags', () => { + const { result } = renderHook(() => useConfig('if-node', createPayload())) + + expect(result.current.readOnly).toBe(false) + expect(result.current.filterVar()).toBe(true) + expect(result.current.filterNumberVar({ type: VarType.number } as never)).toBe(true) + expect(result.current.filterNumberVar({ type: VarType.string } as never)).toBe(false) + expect(result.current.nodesOutputVars).toHaveLength(1) + expect(result.current.nodesOutputNumberVars).toHaveLength(1) + expect(result.current.varsIsVarFileAttribute).toEqual({ 'condition-1': false }) + }) + + it('should manage cases and conditions', () => { + const { result } = renderHook(() => useConfig('if-node', createPayload())) + + result.current.handleAddCase() + result.current.handleRemoveCase('generated-id') + result.current.handleAddCondition('case-1', ['node-1', 'score'], { type: VarType.number } as never) + result.current.handleUpdateCondition('case-1', 'condition-1', { + id: 'condition-1', + varType: VarType.number, + variable_selector: ['node-1', 'score'], + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }) + result.current.handleRemoveCondition('case-1', 'condition-1') + result.current.handleToggleConditionLogicalOperator('case-1') + result.current.handleSortCase([{ + id: 'sortable-1', + case_id: 'case-1', + logical_operator: LogicalOperator.or, + conditions: [], + }]) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + case_id: 'generated-id', + logical_operator: LogicalOperator.and, + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: [ + expect.objectContaining({ + case_id: 'case-1', + logical_operator: LogicalOperator.or, + }), + ], + _targetBranches: [ + { id: 'case-1', name: 'IF' }, + { id: 'false', name: 'ELSE' }, + ], + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + variable_selector: ['node-1', 'score'], + }), + ]), + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'condition-1', + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }), + ]), + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), + ]), + })) + expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('if-node', 'generated-id') + expect(mockUpdateNodeInternals).toHaveBeenCalledWith('if-node') + }) + + it('should manage sub-variable conditions', () => { + const payload = createPayload({ + cases: [{ + case_id: 'case-1', + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'condition-1', + varType: VarType.file, + variable_selector: ['node-1', 'files'], + comparison_operator: ComparisonOperator.exists, + value: '', + sub_variable_condition: { + case_id: 'sub-case-1', + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'sub-1', + key: 'name', + varType: VarType.string, + comparison_operator: ComparisonOperator.contains, + value: '', + }], + }, + }], + }], + }) + const { result } = renderHook(() => useConfig('if-node', payload)) + + result.current.handleAddSubVariableCondition('case-1', 'condition-1', 'name') + result.current.handleUpdateSubVariableCondition('case-1', 'condition-1', 'sub-1', { + id: 'sub-1', + key: 'size', + varType: VarType.string, + comparison_operator: ComparisonOperator.is, + value: '2', + }) + result.current.handleRemoveSubVariableCondition('case-1', 'condition-1', 'sub-1') + result.current.handleToggleSubVariableConditionLogicalOperator('case-1', 'condition-1') + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + key: 'name', + }), + ]), + }), + }), + ]), + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'sub-1', + key: 'size', + value: '2', + }), + ]), + }), + }), + ]), + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + cases: expect.arrayContaining([ + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), + }), + ]), + }), + ]), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/if-else/use-config.helpers.ts b/web/app/components/workflow/nodes/if-else/use-config.helpers.ts new file mode 100644 index 0000000000..b6ed6af84c --- /dev/null +++ b/web/app/components/workflow/nodes/if-else/use-config.helpers.ts @@ -0,0 +1,237 @@ +import type { Branch, Var } from '../../types' +import type { CaseItem, Condition, IfElseNodeType } from './types' +import { produce } from 'immer' +import { v4 as uuid4 } from 'uuid' +import { VarType } from '../../types' +import { LogicalOperator } from './types' +import { + branchNameCorrect, + getOperators, +} from './utils' + +export const filterAllVars = () => true + +export const filterNumberVars = (varPayload: Var) => varPayload.type === VarType.number + +export const getVarsIsVarFileAttribute = ( + cases: IfElseNodeType['cases'], + getIsVarFileAttribute: (valueSelector: string[]) => boolean, +) => { + const conditions: Record = {} + cases?.forEach((caseItem) => { + caseItem.conditions.forEach((condition) => { + if (condition.variable_selector) + conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector) + }) + }) + return conditions +} + +const getTargetBranchesWithNewCase = (targetBranches: Branch[] | undefined, caseId: string) => { + if (!targetBranches) + return targetBranches + + const elseCaseIndex = targetBranches.findIndex(branch => branch.id === 'false') + if (elseCaseIndex < 0) + return targetBranches + + return branchNameCorrect([ + ...targetBranches.slice(0, elseCaseIndex), + { + id: caseId, + name: '', + }, + ...targetBranches.slice(elseCaseIndex), + ]) +} + +export const addCase = (inputs: IfElseNodeType) => produce(inputs, (draft) => { + if (!draft.cases) + return + + const caseId = uuid4() + draft.cases.push({ + case_id: caseId, + logical_operator: LogicalOperator.and, + conditions: [], + }) + draft._targetBranches = getTargetBranchesWithNewCase(draft._targetBranches, caseId) +}) + +export const removeCase = ( + inputs: IfElseNodeType, + caseId: string, +) => produce(inputs, (draft) => { + draft.cases = draft.cases?.filter(item => item.case_id !== caseId) + + if (draft._targetBranches) + draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId)) +}) + +export const sortCases = ( + inputs: IfElseNodeType, + newCases: (CaseItem & { id: string })[], +) => produce(inputs, (draft) => { + draft.cases = newCases.filter(Boolean).map(item => ({ + id: item.id, + case_id: item.case_id, + logical_operator: item.logical_operator, + conditions: item.conditions, + })) + + draft._targetBranches = branchNameCorrect([ + ...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })), + { id: 'false', name: '' }, + ]) +}) + +export const addCondition = ({ + inputs, + caseId, + valueSelector, + variable, + isVarFileAttribute, +}: { + inputs: IfElseNodeType + caseId: string + valueSelector: string[] + variable: Var + isVarFileAttribute: boolean +}) => produce(inputs, (draft) => { + const targetCase = draft.cases?.find(item => item.case_id === caseId) + if (!targetCase) + return + + targetCase.conditions.push({ + id: uuid4(), + varType: variable.type, + variable_selector: valueSelector, + comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0], + value: (variable.type === VarType.boolean || variable.type === VarType.arrayBoolean) ? false : '', + }) +}) + +export const removeCondition = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, +) => produce(inputs, (draft) => { + const targetCase = draft.cases?.find(item => item.case_id === caseId) + if (targetCase) + targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId) +}) + +export const updateCondition = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, + nextCondition: Condition, +) => produce(inputs, (draft) => { + const targetCondition = draft.cases + ?.find(item => item.case_id === caseId) + ?.conditions + .find(item => item.id === conditionId) + + if (targetCondition) + Object.assign(targetCondition, nextCondition) +}) + +export const toggleConditionLogicalOperator = ( + inputs: IfElseNodeType, + caseId: string, +) => produce(inputs, (draft) => { + const targetCase = draft.cases?.find(item => item.case_id === caseId) + if (!targetCase) + return + + targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and + ? LogicalOperator.or + : LogicalOperator.and +}) + +export const addSubVariableCondition = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, + key?: string, +) => produce(inputs, (draft) => { + const condition = draft.cases + ?.find(item => item.case_id === caseId) + ?.conditions + .find(item => item.id === conditionId) + + if (!condition) + return + + if (!condition.sub_variable_condition) { + condition.sub_variable_condition = { + case_id: uuid4(), + logical_operator: LogicalOperator.and, + conditions: [], + } + } + + condition.sub_variable_condition.conditions.push({ + id: uuid4(), + key: key || '', + varType: VarType.string, + comparison_operator: undefined, + value: '', + }) +}) + +export const removeSubVariableCondition = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, + subConditionId: string, +) => produce(inputs, (draft) => { + const subVariableCondition = draft.cases + ?.find(item => item.case_id === caseId) + ?.conditions + .find(item => item.id === conditionId) + ?.sub_variable_condition + + if (!subVariableCondition) + return + + subVariableCondition.conditions = subVariableCondition.conditions.filter(item => item.id !== subConditionId) +}) + +export const updateSubVariableCondition = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, + subConditionId: string, + nextCondition: Condition, +) => produce(inputs, (draft) => { + const targetSubCondition = draft.cases + ?.find(item => item.case_id === caseId) + ?.conditions + .find(item => item.id === conditionId) + ?.sub_variable_condition + ?.conditions + .find(item => item.id === subConditionId) + + if (targetSubCondition) + Object.assign(targetSubCondition, nextCondition) +}) + +export const toggleSubVariableConditionLogicalOperator = ( + inputs: IfElseNodeType, + caseId: string, + conditionId: string, +) => produce(inputs, (draft) => { + const targetSubVariableCondition = draft.cases + ?.find(item => item.case_id === caseId) + ?.conditions + .find(item => item.id === conditionId) + ?.sub_variable_condition + + if (!targetSubVariableCondition) + return + + targetSubVariableCondition.logical_operator = targetSubVariableCondition.logical_operator === LogicalOperator.and + ? LogicalOperator.or + : LogicalOperator.and +}) diff --git a/web/app/components/workflow/nodes/if-else/use-config.ts b/web/app/components/workflow/nodes/if-else/use-config.ts index f7384e1d67..4a5ea25980 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.ts +++ b/web/app/components/workflow/nodes/if-else/use-config.ts @@ -12,33 +12,48 @@ import type { HandleUpdateSubVariableCondition, IfElseNodeType, } from './types' -import { produce } from 'immer' -import { useCallback, useMemo } from 'react' +import { + useCallback, + useMemo, + useRef, +} from 'react' import { useUpdateNodeInternals } from 'reactflow' -import { v4 as uuid4 } from 'uuid' import { useEdgesInteractions, useNodesReadOnly, } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { VarType } from '../../types' -import { LogicalOperator } from './types' -import useIsVarFileAttribute from './use-is-var-file-attribute' import { - branchNameCorrect, - getOperators, -} from './utils' + addCase, + addCondition, + addSubVariableCondition, + filterAllVars, + filterNumberVars, + getVarsIsVarFileAttribute, + removeCase, + removeCondition, + removeSubVariableCondition, + sortCases, + toggleConditionLogicalOperator, + toggleSubVariableConditionLogicalOperator, + updateCondition, + updateSubVariableCondition, +} from './use-config.helpers' +import useIsVarFileAttribute from './use-is-var-file-attribute' const useConfig = (id: string, payload: IfElseNodeType) => { const updateNodeInternals = useUpdateNodeInternals() const { nodesReadOnly: readOnly } = useNodesReadOnly() const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions() const { inputs, setInputs } = useNodeCrud(id, payload) + const inputsRef = useRef(inputs) + const handleInputsChange = useCallback((newInputs: IfElseNodeType) => { + inputsRef.current = newInputs + setInputs(newInputs) + }, [setInputs]) - const filterVar = useCallback(() => { - return true - }, []) + const filterVar = useCallback(() => filterAllVars(), []) const { availableVars, @@ -48,9 +63,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => { filterVar, }) - const filterNumberVar = useCallback((varPayload: Var) => { - return varPayload.type === VarType.number - }, []) + const filterNumberVar = useCallback((varPayload: Var) => filterNumberVars(varPayload), []) const { getIsVarFileAttribute, @@ -61,13 +74,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => { }) const varsIsVarFileAttribute = useMemo(() => { - const conditions: Record = {} - inputs.cases?.forEach((c) => { - c.conditions.forEach((condition) => { - conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector!) - }) - }) - return conditions + return getVarsIsVarFileAttribute(inputs.cases, getIsVarFileAttribute) }, [inputs.cases, getIsVarFileAttribute]) const { @@ -79,177 +86,56 @@ const useConfig = (id: string, payload: IfElseNodeType) => { }) const handleAddCase = useCallback(() => { - const newInputs = produce(inputs, (draft) => { - if (draft.cases) { - const case_id = uuid4() - draft.cases.push({ - case_id, - logical_operator: LogicalOperator.and, - conditions: [], - }) - if (draft._targetBranches) { - const elseCaseIndex = draft._targetBranches.findIndex(branch => branch.id === 'false') - if (elseCaseIndex > -1) { - draft._targetBranches = branchNameCorrect([ - ...draft._targetBranches.slice(0, elseCaseIndex), - { - id: case_id, - name: '', - }, - ...draft._targetBranches.slice(elseCaseIndex), - ]) - } - } - } - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(addCase(inputsRef.current)) + }, [handleInputsChange]) const handleRemoveCase = useCallback((caseId: string) => { - const newInputs = produce(inputs, (draft) => { - draft.cases = draft.cases?.filter(item => item.case_id !== caseId) - - if (draft._targetBranches) - draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId)) - - handleEdgeDeleteByDeleteBranch(id, caseId) - }) - setInputs(newInputs) - }, [inputs, setInputs, id, handleEdgeDeleteByDeleteBranch]) + handleEdgeDeleteByDeleteBranch(id, caseId) + handleInputsChange(removeCase(inputsRef.current, caseId)) + }, [handleEdgeDeleteByDeleteBranch, handleInputsChange, id]) const handleSortCase = useCallback((newCases: (CaseItem & { id: string })[]) => { - const newInputs = produce(inputs, (draft) => { - draft.cases = newCases.filter(Boolean).map(item => ({ - id: item.id, - case_id: item.case_id, - logical_operator: item.logical_operator, - conditions: item.conditions, - })) - - draft._targetBranches = branchNameCorrect([ - ...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })), - { id: 'false', name: '' }, - ]) - }) - setInputs(newInputs) + handleInputsChange(sortCases(inputsRef.current, newCases)) updateNodeInternals(id) - }, [id, inputs, setInputs, updateNodeInternals]) + }, [handleInputsChange, id, updateNodeInternals]) const handleAddCondition = useCallback((caseId, valueSelector, varItem) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) { - targetCase.conditions.push({ - id: uuid4(), - varType: varItem.type, - variable_selector: valueSelector, - comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0], - value: (varItem.type === VarType.boolean || varItem.type === VarType.arrayBoolean) ? false : '', - }) - } - }) - setInputs(newInputs) - }, [getIsVarFileAttribute, inputs, setInputs]) + handleInputsChange(addCondition({ + inputs: inputsRef.current, + caseId, + valueSelector, + variable: varItem, + isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), + })) + }, [getIsVarFileAttribute, handleInputsChange]) const handleRemoveCondition = useCallback((caseId, conditionId) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) - targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId) - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(removeCondition(inputsRef.current, caseId, conditionId)) + }, [handleInputsChange]) const handleUpdateCondition = useCallback((caseId, conditionId, newCondition) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) { - const targetCondition = targetCase.conditions.find(item => item.id === conditionId) - if (targetCondition) - Object.assign(targetCondition, newCondition) - } - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(updateCondition(inputsRef.current, caseId, conditionId, newCondition)) + }, [handleInputsChange]) const handleToggleConditionLogicalOperator = useCallback((caseId) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) - targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(toggleConditionLogicalOperator(inputsRef.current, caseId)) + }, [handleInputsChange]) const handleAddSubVariableCondition = useCallback((caseId: string, conditionId: string, key?: string) => { - const newInputs = produce(inputs, (draft) => { - const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId) - if (!condition) - return - if (!condition?.sub_variable_condition) { - condition.sub_variable_condition = { - case_id: uuid4(), - logical_operator: LogicalOperator.and, - conditions: [], - } - } - const subVarCondition = condition.sub_variable_condition - if (subVarCondition) { - if (!subVarCondition.conditions) - subVarCondition.conditions = [] - - subVarCondition.conditions.push({ - id: uuid4(), - key: key || '', - varType: VarType.string, - comparison_operator: undefined, - value: '', - }) - } - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(addSubVariableCondition(inputsRef.current, caseId, conditionId, key)) + }, [handleInputsChange]) const handleRemoveSubVariableCondition = useCallback((caseId: string, conditionId: string, subConditionId: string) => { - const newInputs = produce(inputs, (draft) => { - const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId) - if (!condition) - return - if (!condition?.sub_variable_condition) - return - const subVarCondition = condition.sub_variable_condition - if (subVarCondition) - subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId) - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(removeSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId)) + }, [handleInputsChange]) const handleUpdateSubVariableCondition = useCallback((caseId, conditionId, subConditionId, newSubCondition) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) { - const targetCondition = targetCase.conditions.find(item => item.id === conditionId) - if (targetCondition && targetCondition.sub_variable_condition) { - const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId) - if (targetSubCondition) - Object.assign(targetSubCondition, newSubCondition) - } - } - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(updateSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId, newSubCondition)) + }, [handleInputsChange]) const handleToggleSubVariableConditionLogicalOperator = useCallback((caseId, conditionId) => { - const newInputs = produce(inputs, (draft) => { - const targetCase = draft.cases?.find(item => item.case_id === caseId) - if (targetCase) { - const targetCondition = targetCase.conditions.find(item => item.id === conditionId) - if (targetCondition && targetCondition.sub_variable_condition) - targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - } - }) - setInputs(newInputs) - }, [inputs, setInputs]) + handleInputsChange(toggleSubVariableConditionLogicalOperator(inputsRef.current, caseId, conditionId)) + }, [handleInputsChange]) return { readOnly, diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts new file mode 100644 index 0000000000..9e5c2126c2 --- /dev/null +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.helpers.spec.ts @@ -0,0 +1,111 @@ +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { + buildIterationChildCopy, + getIterationChildren, + getIterationContainerBounds, + getIterationContainerResize, + getNextChildNodeTypeCount, + getRestrictedIterationPosition, +} from '../use-interactions.helpers' + +const createNode = (overrides: Record = {}) => ({ + id: 'node', + type: 'custom', + position: { x: 0, y: 0 }, + width: 100, + height: 80, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + ...overrides, +}) + +describe('iteration interaction helpers', () => { + it('calculates bounds, resize and drag restriction for iteration containers', () => { + const children = [ + createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }), + createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }), + ] + const bounds = getIterationContainerBounds(children as Node[]) + expect(bounds.rightNode?.id).toBe('b') + expect(bounds.bottomNode?.id).toBe('b') + expect(getIterationContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({ + width: 186, + height: 110, + }) + expect(getRestrictedIterationPosition( + createNode({ + position: { x: -10, y: 160 }, + width: 80, + height: 40, + data: { isInIteration: true }, + }), + createNode({ width: 200, height: 180 }) as Node, + )).toEqual({ x: 16, y: 120 }) + expect(getRestrictedIterationPosition( + createNode({ + position: { x: 180, y: -4 }, + width: 40, + height: 30, + data: { isInIteration: true }, + }), + createNode({ width: 200, height: 180 }) as Node, + )).toEqual({ x: 144, y: 65 }) + }) + + it('filters iteration children and increments per-type counts', () => { + const typeCount = {} as Parameters[0] + expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(3) + expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(4) + expect(getIterationChildren([ + createNode({ id: 'child', parentId: 'iteration-1' }), + createNode({ id: 'start', parentId: 'iteration-1', type: 'custom-iteration-start' }), + createNode({ id: 'other', parentId: 'other-iteration' }), + ] as Node[], 'iteration-1').map(item => item.id)).toEqual(['child']) + }) + + it('keeps bounds, resize and positions empty when no container restriction applies', () => { + expect(getIterationContainerBounds([])).toEqual({}) + expect(getIterationContainerResize(createNode({ width: 300, height: 240 }) as Node, {})).toEqual({ + width: undefined, + height: undefined, + }) + expect(getRestrictedIterationPosition( + createNode({ data: { isInIteration: true } }), + undefined, + )).toEqual({ x: undefined, y: undefined }) + expect(getRestrictedIterationPosition( + createNode({ data: { isInIteration: false } }), + createNode({ width: 200, height: 180 }) as Node, + )).toEqual({ x: undefined, y: undefined }) + }) + + it('builds copied iteration children with iteration metadata', () => { + const child = createNode({ + id: 'child', + position: { x: 12, y: 24 }, + positionAbsolute: { x: 12, y: 24 }, + extent: 'parent', + zIndex: 7, + data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true }, + }) + + const result = buildIterationChildCopy({ + child: child as Node, + childNodeType: BlockEnum.Code, + defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'], + title: 'blocks.code 3', + newNodeId: 'iteration-2', + }) + + expect(result).toEqual(expect.objectContaining({ + parentId: 'iteration-2', + zIndex: 7, + data: expect.objectContaining({ + title: 'blocks.code 3', + iteration_id: 'iteration-2', + selected: false, + _isBundled: false, + }), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx new file mode 100644 index 0000000000..58671a2311 --- /dev/null +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx @@ -0,0 +1,181 @@ +import type { Node } from '@/app/components/workflow/types' +import { renderHook } from '@testing-library/react' +import { + createIterationNode, + createNode, +} from '@/app/components/workflow/__tests__/fixtures' +import { ITERATION_PADDING } from '@/app/components/workflow/constants' +import { BlockEnum } from '@/app/components/workflow/types' +import { useNodeIterationInteractions } from '../use-interactions' + +const mockGetNodes = vi.hoisted(() => vi.fn()) +const mockSetNodes = vi.hoisted(() => vi.fn()) +const mockGenerateNewNode = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', async () => { + const actual = await vi.importActual('reactflow') + return { + ...actual, + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + }), + }), + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Code]: { + defaultValue: { + title: 'Code', + desc: '', + }, + }, + }, + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args), + getNodeCustomTypeByNodeDataType: () => 'custom', +})) + +describe('useNodeIterationInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expand the iteration node when children overflow the bounds', () => { + mockGetNodes.mockReturnValue([ + createIterationNode({ + id: 'iteration-node', + width: 120, + height: 80, + data: { width: 120, height: 80 }, + }), + createNode({ + id: 'child-node', + parentId: 'iteration-node', + position: { x: 100, y: 90 }, + width: 60, + height: 40, + }), + ]) + + const { result } = renderHook(() => useNodeIterationInteractions()) + result.current.handleNodeIterationRerender('iteration-node') + + expect(mockSetNodes).toHaveBeenCalledTimes(1) + const updatedNodes = mockSetNodes.mock.calls[0][0] + const updatedIterationNode = updatedNodes.find((node: Node) => node.id === 'iteration-node') + expect(updatedIterationNode.width).toBe(100 + 60 + ITERATION_PADDING.right) + expect(updatedIterationNode.height).toBe(90 + 40 + ITERATION_PADDING.bottom) + }) + + it('should restrict dragging to the iteration container padding', () => { + mockGetNodes.mockReturnValue([ + createIterationNode({ + id: 'iteration-node', + width: 200, + height: 180, + data: { width: 200, height: 180 }, + }), + ]) + + const { result } = renderHook(() => useNodeIterationInteractions()) + const dragResult = result.current.handleNodeIterationChildDrag(createNode({ + id: 'child-node', + parentId: 'iteration-node', + position: { x: -10, y: -5 }, + width: 80, + height: 60, + data: { type: BlockEnum.Code, title: 'Child', desc: '', isInIteration: true }, + })) + + expect(dragResult.restrictPosition).toEqual({ + x: ITERATION_PADDING.left, + y: ITERATION_PADDING.top, + }) + }) + + it('should rerender the parent iteration node when a child size changes', () => { + mockGetNodes.mockReturnValue([ + createIterationNode({ + id: 'iteration-node', + width: 120, + height: 80, + data: { width: 120, height: 80 }, + }), + createNode({ + id: 'child-node', + parentId: 'iteration-node', + position: { x: 100, y: 90 }, + width: 60, + height: 40, + }), + ]) + + const { result } = renderHook(() => useNodeIterationInteractions()) + result.current.handleNodeIterationChildSizeChange('child-node') + + expect(mockSetNodes).toHaveBeenCalledTimes(1) + }) + + it('should skip iteration rerender when the resized node has no parent', () => { + mockGetNodes.mockReturnValue([ + createNode({ + id: 'standalone-node', + data: { type: BlockEnum.Code, title: 'Standalone', desc: '' }, + }), + ]) + + const { result } = renderHook(() => useNodeIterationInteractions()) + result.current.handleNodeIterationChildSizeChange('standalone-node') + + expect(mockSetNodes).not.toHaveBeenCalled() + }) + + it('should copy iteration children and remap ids', () => { + mockGetNodes.mockReturnValue([ + createIterationNode({ id: 'iteration-node' }), + createNode({ + id: 'child-node', + parentId: 'iteration-node', + data: { type: BlockEnum.Code, title: 'Child', desc: '' }, + }), + createNode({ + id: 'same-type-node', + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + }), + ]) + mockGenerateNewNode.mockReturnValue({ + newNode: createNode({ + id: 'generated', + parentId: 'new-iteration', + data: { type: BlockEnum.Code, title: 'blocks.code 3', desc: '', iteration_id: 'new-iteration' }, + }), + }) + + const { result } = renderHook(() => useNodeIterationInteractions()) + const copyResult = result.current.handleNodeIterationChildrenCopy('iteration-node', 'new-iteration', { existing: 'mapped' }) + + expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({ + type: 'custom', + parentId: 'new-iteration', + })) + expect(copyResult.copyChildren).toHaveLength(1) + expect(copyResult.newIdMapping).toEqual({ + 'existing': 'mapped', + 'child-node': 'new-iterationgenerated0', + }) + }) +}) diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts b/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts new file mode 100644 index 0000000000..1af83c4fc2 --- /dev/null +++ b/web/app/components/workflow/nodes/iteration/use-interactions.helpers.ts @@ -0,0 +1,113 @@ +import type { + BlockEnum, + ChildNodeTypeCount, + Node, +} from '../../types' +import { + ITERATION_PADDING, +} from '../../constants' +import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants' + +type ContainerBounds = { + rightNode?: Node + bottomNode?: Node +} + +export const getIterationContainerBounds = (childrenNodes: Node[]): ContainerBounds => { + return childrenNodes.reduce((acc, node) => { + const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! + ? node + : acc.rightNode + const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! + ? node + : acc.bottomNode + + return { + rightNode: nextRightNode, + bottomNode: nextBottomNode, + } + }, {}) +} + +export const getIterationContainerResize = (currentNode: Node, bounds: ContainerBounds) => { + const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! + ? bounds.rightNode.position.x + bounds.rightNode.width! + ITERATION_PADDING.right + : undefined + const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! + ? bounds.bottomNode.position.y + bounds.bottomNode.height! + ITERATION_PADDING.bottom + : undefined + + return { + width, + height, + } +} + +export const getRestrictedIterationPosition = (node: Node, parentNode?: Node) => { + const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } + + if (!node.data.isInIteration || !parentNode) + return restrictPosition + + if (node.position.y < ITERATION_PADDING.top) + restrictPosition.y = ITERATION_PADDING.top + if (node.position.x < ITERATION_PADDING.left) + restrictPosition.x = ITERATION_PADDING.left + if (node.position.x + node.width! > parentNode.width! - ITERATION_PADDING.right) + restrictPosition.x = parentNode.width! - ITERATION_PADDING.right - node.width! + if (node.position.y + node.height! > parentNode.height! - ITERATION_PADDING.bottom) + restrictPosition.y = parentNode.height! - ITERATION_PADDING.bottom - node.height! + + return restrictPosition +} + +export const getIterationChildren = (nodes: Node[], nodeId: string) => { + return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_ITERATION_START_NODE) +} + +export const getNextChildNodeTypeCount = ( + childNodeTypeCount: ChildNodeTypeCount, + childNodeType: BlockEnum, + nodesWithSameTypeCount: number, +) => { + if (!childNodeTypeCount[childNodeType]) + childNodeTypeCount[childNodeType] = nodesWithSameTypeCount + 1 + else + childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1 + + return childNodeTypeCount[childNodeType] +} + +export const buildIterationChildCopy = ({ + child, + childNodeType, + defaultValue, + title, + newNodeId, +}: { + child: Node + childNodeType: BlockEnum + defaultValue: Node['data'] + title: string + newNodeId: string +}) => { + return { + type: child.type!, + data: { + ...defaultValue, + ...child.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + title, + iteration_id: newNodeId, + type: childNodeType, + }, + position: child.position, + positionAbsolute: child.positionAbsolute, + parentId: newNodeId, + extent: child.extent, + zIndex: child.zIndex, + } +} diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.ts b/web/app/components/workflow/nodes/iteration/use-interactions.ts index 678c71995e..1f1954a112 100644 --- a/web/app/components/workflow/nodes/iteration/use-interactions.ts +++ b/web/app/components/workflow/nodes/iteration/use-interactions.ts @@ -8,14 +8,18 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { useNodesMetaData } from '@/app/components/workflow/hooks' -import { - ITERATION_PADDING, -} from '../../constants' import { generateNewNode, getNodeCustomTypeByNodeDataType, } from '../../utils' -import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants' +import { + buildIterationChildCopy, + getIterationChildren, + getIterationContainerBounds, + getIterationContainerResize, + getNextChildNodeTypeCount, + getRestrictedIterationPosition, +} from './use-interactions.helpers' export const useNodeIterationInteractions = () => { const { t } = useTranslation() @@ -31,40 +35,19 @@ export const useNodeIterationInteractions = () => { const nodes = getNodes() const currentNode = nodes.find(n => n.id === nodeId)! const childrenNodes = nodes.filter(n => n.parentId === nodeId) - let rightNode: Node - let bottomNode: Node + const resize = getIterationContainerResize(currentNode, getIterationContainerBounds(childrenNodes)) - childrenNodes.forEach((n) => { - if (rightNode) { - if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) - rightNode = n - } - else { - rightNode = n - } - if (bottomNode) { - if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!) - bottomNode = n - } - else { - bottomNode = n - } - }) - - const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width! - const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height! - - if (widthShouldExtend || heightShouldExtend) { + if (resize.width || resize.height) { const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { if (n.id === nodeId) { - if (widthShouldExtend) { - n.data.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right - n.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right + if (resize.width) { + n.data.width = resize.width + n.width = resize.width } - if (heightShouldExtend) { - n.data.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom - n.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom + if (resize.height) { + n.data.height = resize.height + n.height = resize.height } } }) @@ -78,25 +61,8 @@ export const useNodeIterationInteractions = () => { const { getNodes } = store.getState() const nodes = getNodes() - const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } - - if (node.data.isInIteration) { - const parentNode = nodes.find(n => n.id === node.parentId) - - if (parentNode) { - if (node.position.y < ITERATION_PADDING.top) - restrictPosition.y = ITERATION_PADDING.top - if (node.position.x < ITERATION_PADDING.left) - restrictPosition.x = ITERATION_PADDING.left - if (node.position.x + node.width! > parentNode!.width! - ITERATION_PADDING.right) - restrictPosition.x = parentNode!.width! - ITERATION_PADDING.right - node.width! - if (node.position.y + node.height! > parentNode!.height! - ITERATION_PADDING.bottom) - restrictPosition.y = parentNode!.height! - ITERATION_PADDING.bottom - node.height! - } - } - return { - restrictPosition, + restrictPosition: getRestrictedIterationPosition(node, nodes.find(n => n.id === node.parentId)), } }, [store]) @@ -113,37 +79,27 @@ export const useNodeIterationInteractions = () => { const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record) => { const { getNodes } = store.getState() const nodes = getNodes() - const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE) + const childrenNodes = getIterationChildren(nodes, nodeId) const newIdMapping = { ...idMapping } const childNodeTypeCount: ChildNodeTypeCount = {} const copyChildren = childrenNodes.map((child, index) => { const childNodeType = child.data.type as BlockEnum const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType) - - if (!childNodeTypeCount[childNodeType]) - childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1 - else - childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1 - + const nextCount = getNextChildNodeTypeCount(childNodeTypeCount, childNodeType, nodesWithSameType.length) + const title = nodesWithSameType.length > 0 + ? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${nextCount}` + : t(`blocks.${childNodeType}`, { ns: 'workflow' }) + const childCopy = buildIterationChildCopy({ + child, + childNodeType, + defaultValue: nodesMetaDataMap![childNodeType].defaultValue as Node['data'], + title, + newNodeId, + }) const { newNode } = generateNewNode({ + ...childCopy, type: getNodeCustomTypeByNodeDataType(childNodeType), - data: { - ...nodesMetaDataMap![childNodeType].defaultValue, - ...child.data, - selected: false, - _isBundled: false, - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: [], - title: nodesWithSameType.length > 0 ? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${childNodeTypeCount[childNodeType]}` : t(`blocks.${childNodeType}`, { ns: 'workflow' }), - iteration_id: newNodeId, - type: childNodeType, - }, - position: child.position, - positionAbsolute: child.positionAbsolute, - parentId: newNodeId, - extent: child.extent, - zIndex: child.zIndex, }) newNode.id = `${newNodeId}${newNode.id + index}` newIdMapping[child.id] = newNode.id @@ -154,7 +110,7 @@ export const useNodeIterationInteractions = () => { copyChildren, newIdMapping, } - }, [store, t]) + }, [nodesMetaDataMap, store, t]) return { handleNodeIterationRerender, diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts new file mode 100644 index 0000000000..d7feadd562 --- /dev/null +++ b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.helpers.spec.ts @@ -0,0 +1,108 @@ +import type { ListFilterNodeType } from '../types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { OrderBy } from '../types' +import { + buildFilterCondition, + canFilterVariable, + getItemVarType, + getItemVarTypeShowName, + supportsSubVariable, + updateExtractEnabled, + updateExtractSerial, + updateFilterCondition, + updateFilterEnabled, + updateLimit, + updateListFilterVariable, + updateOrderByEnabled, + updateOrderByKey, + updateOrderByType, +} from '../use-config.helpers' + +const createInputs = (): ListFilterNodeType => ({ + title: 'List Filter', + desc: '', + type: BlockEnum.ListFilter, + variable: ['node', 'list'], + var_type: VarType.arrayString, + item_var_type: VarType.string, + filter_by: { + enabled: false, + conditions: [{ key: '', comparison_operator: 'contains', value: '' }], + }, + extract_by: { + enabled: false, + serial: '', + }, + order_by: { + enabled: false, + key: '', + value: OrderBy.DESC, + }, + limit: { + enabled: false, + size: 20, + }, +} as unknown as ListFilterNodeType) + +describe('list operator use-config helpers', () => { + it('maps item var types, labels and filter support', () => { + expect(getItemVarType(VarType.arrayNumber)).toBe(VarType.number) + expect(getItemVarType(VarType.arrayBoolean)).toBe(VarType.boolean) + expect(getItemVarType(undefined)).toBe(VarType.string) + expect(getItemVarTypeShowName(undefined, false)).toBe('?') + expect(getItemVarTypeShowName(VarType.number, true)).toBe('Number') + expect(supportsSubVariable(VarType.arrayFile)).toBe(true) + expect(supportsSubVariable(VarType.arrayString)).toBe(false) + expect(canFilterVariable({ type: VarType.arrayFile } as never)).toBe(true) + expect(canFilterVariable({ type: VarType.string } as never)).toBe(false) + }) + + it('builds default conditions and updates selected variable metadata', () => { + expect(buildFilterCondition({ + itemVarType: VarType.boolean, + isFileArray: false, + })).toEqual(expect.objectContaining({ + key: '', + value: false, + })) + + expect(buildFilterCondition({ + itemVarType: VarType.string, + isFileArray: true, + })).toEqual(expect.objectContaining({ + key: 'name', + value: '', + })) + + const nextInputs = updateListFilterVariable({ + inputs: { + ...createInputs(), + order_by: { enabled: true, key: '', value: OrderBy.DESC }, + }, + variable: ['node', 'files'], + varType: VarType.arrayFile, + itemVarType: VarType.file, + }) + expect(nextInputs.var_type).toBe(VarType.arrayFile) + expect(nextInputs.filter_by.conditions[0]).toEqual(expect.objectContaining({ key: 'name' })) + expect(nextInputs.order_by.key).toBe('name') + }) + + it('updates filter, extract, limit and order by sections', () => { + const condition = { key: 'size', comparison_operator: '>', value: '10' } + expect(updateFilterEnabled(createInputs(), true).filter_by.enabled).toBe(true) + expect(updateFilterCondition(createInputs(), condition as ListFilterNodeType['filter_by']['conditions'][number]).filter_by.conditions[0]).toEqual(condition) + expect(updateLimit(createInputs(), { enabled: true, size: 10 }).limit).toEqual({ enabled: true, size: 10 }) + expect(updateExtractEnabled(createInputs(), true).extract_by).toEqual({ enabled: true, serial: '1' }) + expect(updateExtractSerial(createInputs(), '2').extract_by.serial).toBe('2') + + const orderEnabled = updateOrderByEnabled(createInputs(), true, true) + expect(orderEnabled.order_by).toEqual(expect.objectContaining({ + enabled: true, + key: 'name', + value: OrderBy.ASC, + })) + expect(updateOrderByKey(createInputs(), 'created_at').order_by.key).toBe('created_at') + expect(updateOrderByType(createInputs(), OrderBy.DESC).order_by.value).toBe(OrderBy.DESC) + }) +}) diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..f6c3dab414 --- /dev/null +++ b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx @@ -0,0 +1,183 @@ +import type { ListFilterNodeType } from '../types' +import { renderHook } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils' +import { ComparisonOperator } from '../../if-else/types' +import { OrderBy } from '../types' +import useConfig from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockGetCurrentVariableType = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useIsChatMode: () => false, + useWorkflow: () => ({ + getBeforeNodesInSameBranch: () => [ + { id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } }, + ], + }), + useWorkflowVariables: () => ({ + getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('reactflow', async () => { + const actual = await vi.importActual('reactflow') + return { + ...actual, + useStoreApi: () => ({ + getState: () => ({ + getNodes: () => [ + { id: 'list-node', parentId: 'iteration-parent' }, + { id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } }, + ], + }), + }), + } +}) + +const createPayload = (overrides: Partial = {}): ListFilterNodeType => ({ + title: 'List Filter', + desc: '', + type: BlockEnum.ListFilter, + variable: ['node-1', 'items'], + var_type: VarType.arrayString, + item_var_type: VarType.string, + filter_by: { + enabled: true, + conditions: [{ + key: '', + comparison_operator: ComparisonOperator.equal, + value: '', + }], + }, + extract_by: { + enabled: false, + serial: '', + }, + order_by: { + enabled: false, + key: '', + value: OrderBy.DESC, + }, + limit: { + enabled: false, + size: 10, + }, + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetCurrentVariableType.mockReturnValue(VarType.arrayString) + }) + + it('should expose derived variable metadata and filter array-like vars', () => { + const { result } = renderHook(() => useConfig('list-node', createPayload())) + + expect(result.current.readOnly).toBe(false) + expect(result.current.varType).toBe(VarType.arrayString) + expect(result.current.itemVarType).toBe(VarType.string) + expect(result.current.itemVarTypeShowName).toBe('String') + expect(result.current.hasSubVariable).toBe(false) + expect(result.current.filterVar({ type: VarType.arrayBoolean } as never)).toBe(true) + expect(result.current.filterVar({ type: VarType.object } as never)).toBe(false) + }) + + it('should reset filter conditions when the variable changes to file arrays', () => { + mockGetCurrentVariableType.mockReturnValue(VarType.arrayFile) + const payload = createPayload({ + order_by: { + enabled: true, + key: '', + value: OrderBy.DESC, + }, + }) + const { result } = renderHook(() => useConfig('list-node', payload)) + + result.current.handleVarChanges(['node-2', 'files']) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + variable: ['node-2', 'files'], + var_type: VarType.arrayFile, + item_var_type: VarType.file, + filter_by: { + enabled: true, + conditions: [{ + key: 'name', + comparison_operator: ComparisonOperator.contains, + value: '', + }], + }, + order_by: expect.objectContaining({ + key: 'name', + }), + })) + }) + + it('should update filter, extract, limit and order-by settings', () => { + const { result } = renderHook(() => useConfig('list-node', createPayload())) + + result.current.handleFilterEnabledChange(false) + result.current.handleFilterChange({ + key: 'size', + comparison_operator: ComparisonOperator.largerThan, + value: 3, + }) + result.current.handleLimitChange({ enabled: true, size: 5 }) + result.current.handleExtractsEnabledChange(true) + result.current.handleExtractsChange('2') + result.current.handleOrderByEnabledChange(true) + result.current.handleOrderByKeyChange('size') + result.current.handleOrderByTypeChange(OrderBy.ASC)() + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + filter_by: expect.objectContaining({ enabled: false }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + filter_by: expect.objectContaining({ + conditions: [{ + key: 'size', + comparison_operator: ComparisonOperator.largerThan, + value: 3, + }], + }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + limit: { enabled: true, size: 5 }, + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + extract_by: { enabled: true, serial: '1' }, + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + extract_by: { enabled: false, serial: '2' }, + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + order_by: expect.objectContaining({ + enabled: true, + value: OrderBy.ASC, + key: '', + }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + order_by: expect.objectContaining({ + enabled: false, + key: 'size', + value: OrderBy.DESC, + }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + order_by: expect.objectContaining({ + enabled: false, + key: '', + value: OrderBy.ASC, + }), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx new file mode 100644 index 0000000000..9b37350395 --- /dev/null +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx @@ -0,0 +1,310 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import { VarType } from '../../../../types' +import { ComparisonOperator } from '../../../if-else/types' +import FilterCondition from '../filter-condition' + +const { mockUseAvailableVarList } = vi.hoisted(() => ({ + mockUseAvailableVarList: vi.fn((_nodeId: string, _options: unknown) => ({ + availableVars: [], + availableNodesWithParent: [], + })), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + default: (nodeId: string, options: unknown) => mockUseAvailableVarList(nodeId, options), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({ + default: ({ + value, + onChange, + onFocusChange, + readOnly, + placeholder, + className, + }: { + value: string + onChange: (value: string) => void + onFocusChange?: (value: boolean) => void + readOnly?: boolean + placeholder?: string + className?: string + }) => ( + onChange(e.target.value)} + onFocus={() => onFocusChange?.(true)} + onBlur={() => onFocusChange?.(false)} + readOnly={readOnly} + placeholder={placeholder} + /> + ), +})) + +vi.mock('../../../../panel/chat-variable-panel/components/bool-value', () => ({ + default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({ + default: ({ + value, + onSelect, + }: { + value: string + onSelect: (value: string) => void + }) => ( + + ), +})) + +vi.mock('../sub-variable-picker', () => ({ + default: ({ + value, + onChange, + }: { + value: string + onChange: (value: string) => void + }) => ( + + ), +})) + +describe('FilterCondition', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableVarList.mockReturnValue({ + availableVars: [], + availableNodesWithParent: [], + }) + }) + + it('should render a select input for array-backed file conditions and update array values', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByText(/operator:/)).toBeInTheDocument() + expect(screen.getByText(/sub-variable:/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.doc' })) + await user.click(screen.getByText('workflow.nodes.ifElse.optionName.image')) + + expect(onChange).toHaveBeenCalledWith({ + key: 'type', + comparison_operator: ComparisonOperator.in, + value: ['image'], + }) + }) + + it('should render a boolean value control for boolean variables', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'false' })) + + expect(onChange).toHaveBeenCalledWith({ + key: 'enabled', + comparison_operator: ComparisonOperator.equal, + value: true, + }) + }) + + it('should render a supported variable input, apply focus styles, and filter vars by expected type', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + const variableInput = screen.getByRole('textbox', { name: 'variable-input' }) + expect(variableInput).toHaveAttribute('placeholder', 'workflow.nodes.http.insertVarPlaceholder') + + await user.click(variableInput) + expect(variableInput.className).toContain('border-components-input-border-active') + + fireEvent.change(variableInput, { target: { value: 'draft next' } }) + expect(onChange).toHaveBeenLastCalledWith({ + key: 'name', + comparison_operator: ComparisonOperator.equal, + value: 'draft next', + }) + + const config = mockUseAvailableVarList.mock.calls[0]?.[1] as unknown as { + filterVar: (varPayload: { type: VarType }) => boolean + } + expect(config.filterVar({ type: VarType.string })).toBe(true) + expect(config.filterVar({ type: VarType.number })).toBe(false) + }) + + it('should reset operator and value when the sub variable changes', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'sub-variable:' })) + + expect(onChange).toHaveBeenCalledWith({ + key: 'size', + comparison_operator: ComparisonOperator.largerThan, + value: '', + }) + }) + + it('should render fallback inputs for unsupported keys and hide value inputs for no-value operators', async () => { + const onChange = vi.fn() + + const { rerender } = render( + , + ) + + const numberInput = screen.getByRole('spinbutton') + fireEvent.change(numberInput, { target: { value: '42' } }) + + expect(onChange).toHaveBeenLastCalledWith({ + key: 'custom_field', + comparison_operator: ComparisonOperator.equal, + value: '42', + }) + + rerender( + , + ) + + expect(screen.queryByRole('textbox', { name: 'variable-input' })).not.toBeInTheDocument() + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() + }) + + it('should build transfer-method options and keep empty select option lists stable for unsupported keys', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + const { rerender } = render( + , + ) + + await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.localUpload' })) + await user.click(screen.getByText('workflow.nodes.ifElse.optionName.url')) + expect(onChange).toHaveBeenCalledWith({ + key: 'transfer_method', + comparison_operator: ComparisonOperator.in, + value: [TransferMethod.remote_url], + }) + + rerender( + , + ) + + expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx index e54bb6ee10..f03b0fdc28 100644 --- a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx @@ -17,6 +17,8 @@ import { ComparisonOperator } from '../../if-else/types' import { comparisonOperatorNotRequireValue, getOperators } from '../../if-else/utils' import SubVariablePicker from './sub-variable-picker' +type VariableInputProps = React.ComponentProps + const optionNameI18NPrefix = 'nodes.ifElse.optionName' const VAR_INPUT_SUPPORTED_KEYS: Record = { @@ -37,6 +39,147 @@ type Props = { nodeId: string } +const getExpectedVarType = (condition: Condition, varType: VarType) => { + return condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType +} + +const getSelectOptions = ( + condition: Condition, + isSelect: boolean, + t: ReturnType['t'], +) => { + if (!isSelect) + return [] + + if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { + return FILE_TYPE_OPTIONS.map(item => ({ + name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), + value: item.value, + })) + } + + if (condition.key === 'transfer_method') { + return TRANSFER_METHOD.map(item => ({ + name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), + value: item.value, + })) + } + + return [] +} + +const getFallbackInputType = ({ + hasSubVariable, + condition, + varType, +}: { + hasSubVariable: boolean + condition: Condition + varType: VarType +}) => { + return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) + ? 'number' + : 'text' +} + +const ValueInput = ({ + comparisonOperator, + isSelect, + isArrayValue, + isBoolean, + supportVariableInput, + selectOptions, + condition, + readOnly, + availableVars, + availableNodesWithParent, + onFocusChange, + onChange, + hasSubVariable, + varType, + t, +}: { + comparisonOperator: ComparisonOperator + isSelect: boolean + isArrayValue: boolean + isBoolean: boolean + supportVariableInput: boolean + selectOptions: Array<{ name: string, value: string }> + condition: Condition + readOnly: boolean + availableVars: VariableInputProps['nodesOutputVars'] + availableNodesWithParent: VariableInputProps['availableNodes'] + onFocusChange: (value: boolean) => void + onChange: (value: unknown) => void + hasSubVariable: boolean + varType: VarType + t: ReturnType['t'] +}) => { + const [isFocus, setIsFocus] = useState(false) + + const handleFocusChange = (value: boolean) => { + setIsFocus(value) + onFocusChange(value) + } + + if (comparisonOperatorNotRequireValue(comparisonOperator)) + return null + + if (isSelect) { + return ( + + ) + } + + return ( + onChange(e.target.value)} + readOnly={readOnly} + /> + ) +} + const FilterCondition: FC = ({ condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' }, varType, @@ -46,9 +189,8 @@ const FilterCondition: FC = ({ nodeId, }) => { const { t } = useTranslation() - const [isFocus, setIsFocus] = useState(false) - const expectedVarType = condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType + const expectedVarType = getExpectedVarType(condition, varType) const supportVariableInput = !!expectedVarType const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { @@ -62,24 +204,7 @@ const FilterCondition: FC = ({ const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type' const isBoolean = varType === VarType.boolean - const selectOptions = useMemo(() => { - if (isSelect) { - if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { - return FILE_TYPE_OPTIONS.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), - value: item.value, - })) - } - if (condition.key === 'transfer_method') { - return TRANSFER_METHOD.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), - value: item.value, - })) - } - return [] - } - return [] - }, [condition.comparison_operator, condition.key, isSelect, t]) + const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t]) const handleChange = useCallback((key: string) => { return (value: any) => { @@ -100,67 +225,6 @@ const FilterCondition: FC = ({ }) }, [onChange, expectedVarType]) - // Extract input rendering logic to avoid nested ternary - let inputElement: React.ReactNode = null - if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) { - if (isSelect) { - inputElement = ( - - ) - } - else { - inputElement = ( - handleChange('value')(e.target.value)} - readOnly={readOnly} - /> - ) - } - } - return (
{hasSubVariable && ( @@ -179,7 +243,23 @@ const FilterCondition: FC = ({ file={hasSubVariable ? { key: condition.key } : undefined} disabled={readOnly} /> - {inputElement} + {}} + onChange={handleChange('value')} + hasSubVariable={hasSubVariable} + varType={varType} + t={t} + />
) diff --git a/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts b/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts new file mode 100644 index 0000000000..114a1f0ac5 --- /dev/null +++ b/web/app/components/workflow/nodes/list-operator/use-config.helpers.ts @@ -0,0 +1,150 @@ +import type { ValueSelector, Var, VarType } from '../../types' +import type { Condition, Limit, ListFilterNodeType } from './types' +import { produce } from 'immer' +import { VarType as WorkflowVarType } from '../../types' +import { getOperators } from '../if-else/utils' +import { OrderBy } from './types' + +export const getItemVarType = (varType?: VarType) => { + switch (varType) { + case WorkflowVarType.arrayNumber: + return WorkflowVarType.number + case WorkflowVarType.arrayString: + return WorkflowVarType.string + case WorkflowVarType.arrayFile: + return WorkflowVarType.file + case WorkflowVarType.arrayObject: + return WorkflowVarType.object + case WorkflowVarType.arrayBoolean: + return WorkflowVarType.boolean + default: + return varType ?? WorkflowVarType.string + } +} + +export const getItemVarTypeShowName = (itemVarType?: VarType, hasVariable?: boolean) => { + if (!hasVariable) + return '?' + + const fallbackType = itemVarType || WorkflowVarType.string + return `${fallbackType.substring(0, 1).toUpperCase()}${fallbackType.substring(1)}` +} + +export const supportsSubVariable = (varType?: VarType) => varType === WorkflowVarType.arrayFile + +export const canFilterVariable = (varPayload: Var) => { + return [ + WorkflowVarType.arrayNumber, + WorkflowVarType.arrayString, + WorkflowVarType.arrayBoolean, + WorkflowVarType.arrayFile, + ].includes(varPayload.type) +} + +export const buildFilterCondition = ({ + itemVarType, + isFileArray, + existingKey, +}: { + itemVarType?: VarType + isFileArray: boolean + existingKey?: string +}): Condition => ({ + key: (isFileArray && !existingKey) ? 'name' : '', + comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0], + value: itemVarType === WorkflowVarType.boolean ? false : '', +}) + +export const updateListFilterVariable = ({ + inputs, + variable, + varType, + itemVarType, +}: { + inputs: ListFilterNodeType + variable: ValueSelector + varType: VarType + itemVarType: VarType +}) => produce(inputs, (draft) => { + const isFileArray = varType === WorkflowVarType.arrayFile + + draft.variable = variable + draft.var_type = varType + draft.item_var_type = itemVarType + draft.filter_by.conditions = [ + buildFilterCondition({ + itemVarType, + isFileArray, + existingKey: draft.filter_by.conditions[0]?.key, + }), + ] + + if (isFileArray && draft.order_by.enabled && !draft.order_by.key) + draft.order_by.key = 'name' +}) + +export const updateFilterEnabled = ( + inputs: ListFilterNodeType, + enabled: boolean, +) => produce(inputs, (draft) => { + draft.filter_by.enabled = enabled + if (enabled && !draft.filter_by.conditions) + draft.filter_by.conditions = [] +}) + +export const updateFilterCondition = ( + inputs: ListFilterNodeType, + condition: Condition, +) => produce(inputs, (draft) => { + draft.filter_by.conditions[0] = condition +}) + +export const updateLimit = ( + inputs: ListFilterNodeType, + limit: Limit, +) => produce(inputs, (draft) => { + draft.limit = limit +}) + +export const updateExtractEnabled = ( + inputs: ListFilterNodeType, + enabled: boolean, +) => produce(inputs, (draft) => { + draft.extract_by.enabled = enabled + if (enabled) + draft.extract_by.serial = '1' +}) + +export const updateExtractSerial = ( + inputs: ListFilterNodeType, + value: string, +) => produce(inputs, (draft) => { + draft.extract_by.serial = value +}) + +export const updateOrderByEnabled = ( + inputs: ListFilterNodeType, + enabled: boolean, + hasSubVariable: boolean, +) => produce(inputs, (draft) => { + draft.order_by.enabled = enabled + if (enabled) { + draft.order_by.value = OrderBy.ASC + if (hasSubVariable && !draft.order_by.key) + draft.order_by.key = 'name' + } +}) + +export const updateOrderByKey = ( + inputs: ListFilterNodeType, + key: string, +) => produce(inputs, (draft) => { + draft.order_by.key = key +}) + +export const updateOrderByType = ( + inputs: ListFilterNodeType, + type: OrderBy, +) => produce(inputs, (draft) => { + draft.order_by.value = type +}) diff --git a/web/app/components/workflow/nodes/list-operator/use-config.ts b/web/app/components/workflow/nodes/list-operator/use-config.ts index 72f92bfea4..5a3b8d3a65 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.ts +++ b/web/app/components/workflow/nodes/list-operator/use-config.ts @@ -1,6 +1,5 @@ import type { ValueSelector, Var } from '../../types' -import type { Condition, Limit, ListFilterNodeType } from './types' -import { produce } from 'immer' +import type { Condition, Limit, ListFilterNodeType, OrderBy } from './types' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' import { @@ -10,9 +9,21 @@ import { useWorkflowVariables, } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { VarType } from '../../types' -import { getOperators } from '../if-else/utils' -import { OrderBy } from './types' +import { + canFilterVariable, + getItemVarType, + getItemVarTypeShowName, + supportsSubVariable, + updateExtractEnabled, + updateExtractSerial, + updateFilterCondition, + updateFilterEnabled, + updateLimit, + updateListFilterVariable, + updateOrderByEnabled, + updateOrderByKey, + updateOrderByType, +} from './use-config.helpers' const useConfig = (id: string, payload: ListFilterNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -45,127 +56,59 @@ const useConfig = (id: string, payload: ListFilterNodeType) => { isChatMode, isConstant: false, }) - let itemVarType - switch (varType) { - case VarType.arrayNumber: - itemVarType = VarType.number - break - case VarType.arrayString: - itemVarType = VarType.string - break - case VarType.arrayFile: - itemVarType = VarType.file - break - case VarType.arrayObject: - itemVarType = VarType.object - break - case VarType.arrayBoolean: - itemVarType = VarType.boolean - break - default: - itemVarType = varType - } + const itemVarType = getItemVarType(varType) return { varType, itemVarType } }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode]) const { varType, itemVarType } = getType() - const itemVarTypeShowName = useMemo(() => { - if (!inputs.variable) - return '?' - return [(itemVarType || VarType.string).substring(0, 1).toUpperCase(), (itemVarType || VarType.string).substring(1)].join('') - }, [inputs.variable, itemVarType]) + const itemVarTypeShowName = useMemo(() => getItemVarTypeShowName(itemVarType, !!inputs.variable), [inputs.variable, itemVarType]) - const hasSubVariable = [VarType.arrayFile].includes(varType) + const hasSubVariable = supportsSubVariable(varType) const handleVarChanges = useCallback((variable: ValueSelector | string) => { - const newInputs = produce(inputs, (draft) => { - draft.variable = variable as ValueSelector - const { varType, itemVarType } = getType(draft.variable) - const isFileArray = varType === VarType.arrayFile - - draft.var_type = varType - draft.item_var_type = itemVarType - draft.filter_by.conditions = [{ - key: (isFileArray && !draft.filter_by.conditions[0]?.key) ? 'name' : '', - comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0], - value: itemVarType === VarType.boolean ? false : '', - }] - if (isFileArray && draft.order_by.enabled && !draft.order_by.key) - draft.order_by.key = 'name' - }) - setInputs(newInputs) + const nextType = getType(variable as ValueSelector) + setInputs(updateListFilterVariable({ + inputs, + variable: variable as ValueSelector, + varType: nextType.varType, + itemVarType: nextType.itemVarType, + })) }, [getType, inputs, setInputs]) - const filterVar = useCallback((varPayload: Var) => { - // Don't know the item struct of VarType.arrayObject, so not support it - return [VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayFile].includes(varPayload.type) - }, []) + const filterVar = useCallback((varPayload: Var) => canFilterVariable(varPayload), []) const handleFilterEnabledChange = useCallback((enabled: boolean) => { - const newInputs = produce(inputs, (draft) => { - draft.filter_by.enabled = enabled - if (enabled && !draft.filter_by.conditions) - draft.filter_by.conditions = [] - }) - setInputs(newInputs) - }, [hasSubVariable, inputs, setInputs]) + setInputs(updateFilterEnabled(inputs, enabled)) + }, [inputs, setInputs]) const handleFilterChange = useCallback((condition: Condition) => { - const newInputs = produce(inputs, (draft) => { - draft.filter_by.conditions[0] = condition - }) - setInputs(newInputs) + setInputs(updateFilterCondition(inputs, condition)) }, [inputs, setInputs]) const handleLimitChange = useCallback((limit: Limit) => { - const newInputs = produce(inputs, (draft) => { - draft.limit = limit - }) - setInputs(newInputs) + setInputs(updateLimit(inputs, limit)) }, [inputs, setInputs]) const handleExtractsEnabledChange = useCallback((enabled: boolean) => { - const newInputs = produce(inputs, (draft) => { - draft.extract_by.enabled = enabled - if (enabled) - draft.extract_by.serial = '1' - }) - setInputs(newInputs) + setInputs(updateExtractEnabled(inputs, enabled)) }, [inputs, setInputs]) const handleExtractsChange = useCallback((value: string) => { - const newInputs = produce(inputs, (draft) => { - draft.extract_by.serial = value - }) - setInputs(newInputs) + setInputs(updateExtractSerial(inputs, value)) }, [inputs, setInputs]) const handleOrderByEnabledChange = useCallback((enabled: boolean) => { - const newInputs = produce(inputs, (draft) => { - draft.order_by.enabled = enabled - if (enabled) { - draft.order_by.value = OrderBy.ASC - if (hasSubVariable && !draft.order_by.key) - draft.order_by.key = 'name' - } - }) - setInputs(newInputs) + setInputs(updateOrderByEnabled(inputs, enabled, hasSubVariable)) }, [hasSubVariable, inputs, setInputs]) const handleOrderByKeyChange = useCallback((key: string) => { - const newInputs = produce(inputs, (draft) => { - draft.order_by.key = key - }) - setInputs(newInputs) + setInputs(updateOrderByKey(inputs, key)) }, [inputs, setInputs]) const handleOrderByTypeChange = useCallback((type: OrderBy) => { return () => { - const newInputs = produce(inputs, (draft) => { - draft.order_by.value = type - }) - setInputs(newInputs) + setInputs(updateOrderByType(inputs, type)) } }, [inputs, setInputs]) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts new file mode 100644 index 0000000000..33e67f84b7 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.helpers.spec.ts @@ -0,0 +1,216 @@ +import type { LoopNodeType } from '../types' +import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types' +import { createUuidModuleMock } from '../../__tests__/use-config-test-utils' +import { ComparisonOperator, LogicalOperator } from '../types' +import { + addBreakCondition, + addLoopVariable, + addSubVariableCondition, + canUseAsLoopInput, + removeBreakCondition, + removeLoopVariable, + removeSubVariableCondition, + toggleConditionOperator, + toggleSubVariableConditionOperator, + updateBreakCondition, + updateErrorHandleMode, + updateLoopCount, + updateLoopVariable, + updateSubVariableCondition, +} from '../use-config.helpers' + +const mockUuid = vi.hoisted(() => vi.fn()) + +vi.mock('uuid', () => createUuidModuleMock(() => mockUuid())) + +const createInputs = (overrides: Partial = {}): LoopNodeType => ({ + title: 'Loop', + desc: '', + type: BlockEnum.Loop, + start_node_id: 'start-node', + loop_count: 3, + error_handle_mode: ErrorHandleMode.Terminated, + logical_operator: LogicalOperator.and, + break_conditions: [], + loop_variables: [], + ...overrides, +}) + +describe('loop use-config helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('canUseAsLoopInput', () => { + it.each([ + VarType.array, + VarType.arrayString, + VarType.arrayNumber, + VarType.arrayObject, + VarType.arrayFile, + ])('should accept %s loop inputs', (type) => { + expect(canUseAsLoopInput({ type } as never)).toBe(true) + }) + + it('should reject non-array loop inputs', () => { + expect(canUseAsLoopInput({ type: VarType.string } as never)).toBe(false) + }) + }) + + it('should update error handling, loop count and logical operators immutably', () => { + const inputs = createInputs() + + const withMode = updateErrorHandleMode(inputs, ErrorHandleMode.ContinueOnError) + const withCount = updateLoopCount(withMode, 6) + const toggled = toggleConditionOperator(withCount) + const toggledBack = toggleConditionOperator(toggled) + + expect(withMode.error_handle_mode).toBe(ErrorHandleMode.ContinueOnError) + expect(withCount.loop_count).toBe(6) + expect(toggled.logical_operator).toBe(LogicalOperator.or) + expect(toggledBack.logical_operator).toBe(LogicalOperator.and) + expect(inputs.error_handle_mode).toBe(ErrorHandleMode.Terminated) + expect(inputs.loop_count).toBe(3) + }) + + it('should add, update and remove break conditions for regular and file attributes', () => { + mockUuid + .mockReturnValueOnce('condition-1') + .mockReturnValueOnce('condition-2') + + const withBooleanCondition = addBreakCondition({ + inputs: createInputs({ break_conditions: undefined }), + valueSelector: ['tool-node', 'enabled'], + variable: { type: VarType.boolean }, + isVarFileAttribute: false, + }) + const withFileCondition = addBreakCondition({ + inputs: withBooleanCondition, + valueSelector: ['tool-node', 'file', 'transfer_method'], + variable: { type: VarType.file }, + isVarFileAttribute: true, + }) + const updated = updateBreakCondition(withFileCondition, 'condition-2', { + id: 'condition-2', + varType: VarType.file, + key: 'transfer_method', + variable_selector: ['tool-node', 'file', 'transfer_method'], + comparison_operator: ComparisonOperator.notIn, + value: [VarType.file], + }) + const removed = removeBreakCondition(updated, 'condition-1') + + expect(withBooleanCondition.break_conditions).toEqual([ + expect.objectContaining({ + id: 'condition-1', + varType: VarType.boolean, + comparison_operator: ComparisonOperator.is, + value: 'false', + }), + ]) + expect(withFileCondition.break_conditions?.[1]).toEqual(expect.objectContaining({ + id: 'condition-2', + varType: VarType.file, + comparison_operator: ComparisonOperator.in, + value: '', + })) + expect(updated.break_conditions?.[1]).toEqual(expect.objectContaining({ + comparison_operator: ComparisonOperator.notIn, + value: [VarType.file], + })) + expect(removed.break_conditions).toEqual([ + expect.objectContaining({ id: 'condition-2' }), + ]) + }) + + it('should manage nested sub-variable conditions and ignore missing targets', () => { + mockUuid + .mockReturnValueOnce('sub-condition-1') + .mockReturnValueOnce('sub-condition-2') + + const inputs = createInputs({ + break_conditions: [{ + id: 'condition-1', + varType: VarType.file, + key: 'name', + variable_selector: ['tool-node', 'file'], + comparison_operator: ComparisonOperator.contains, + value: '', + }], + }) + + const untouched = addSubVariableCondition(inputs, 'missing-condition') + const withKeyedSubCondition = addSubVariableCondition(inputs, 'condition-1', 'transfer_method') + const withDefaultKeySubCondition = addSubVariableCondition(withKeyedSubCondition, 'condition-1') + const updated = updateSubVariableCondition(withDefaultKeySubCondition, 'condition-1', 'sub-condition-1', { + id: 'sub-condition-1', + key: 'transfer_method', + varType: VarType.string, + comparison_operator: ComparisonOperator.notIn, + value: ['remote_url'], + }) + const toggled = toggleSubVariableConditionOperator(updated, 'condition-1') + const removed = removeSubVariableCondition(toggled, 'condition-1', 'sub-condition-1') + const unchangedAfterMissingRemove = removeSubVariableCondition(removed, 'missing-condition', 'sub-condition-2') + + expect(untouched).toEqual(inputs) + expect(withKeyedSubCondition.break_conditions?.[0].sub_variable_condition).toEqual({ + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'sub-condition-1', + key: 'transfer_method', + varType: VarType.string, + comparison_operator: ComparisonOperator.in, + value: '', + }], + }) + expect(withDefaultKeySubCondition.break_conditions?.[0].sub_variable_condition?.conditions[1]).toEqual({ + id: 'sub-condition-2', + key: '', + varType: VarType.string, + comparison_operator: undefined, + value: '', + }) + expect(updated.break_conditions?.[0].sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({ + comparison_operator: ComparisonOperator.notIn, + value: ['remote_url'], + })) + expect(toggled.break_conditions?.[0].sub_variable_condition?.logical_operator).toBe(LogicalOperator.or) + expect(removed.break_conditions?.[0].sub_variable_condition?.conditions).toEqual([ + expect.objectContaining({ id: 'sub-condition-2' }), + ]) + expect(unchangedAfterMissingRemove).toEqual(removed) + }) + + it('should add, update and remove loop variables without mutating the source inputs', () => { + mockUuid.mockReturnValueOnce('loop-variable-1') + + const inputs = createInputs({ loop_variables: undefined }) + const added = addLoopVariable(inputs) + const updated = updateLoopVariable(added, 'loop-variable-1', { + label: 'Loop Value', + value_type: ValueType.variable, + value: ['tool-node', 'result'], + }) + const unchanged = updateLoopVariable(updated, 'missing-loop-variable', { label: 'ignored' }) + const removed = removeLoopVariable(unchanged, 'loop-variable-1') + + expect(added.loop_variables).toEqual([{ + id: 'loop-variable-1', + label: '', + var_type: VarType.string, + value_type: ValueType.constant, + value: '', + }]) + expect(updated.loop_variables).toEqual([{ + id: 'loop-variable-1', + label: 'Loop Value', + var_type: VarType.string, + value_type: ValueType.variable, + value: ['tool-node', 'result'], + }]) + expect(unchanged).toEqual(updated) + expect(removed.loop_variables).toEqual([]) + expect(inputs.loop_variables).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..ef09dbf165 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx @@ -0,0 +1,221 @@ +import type { LoopNodeType } from '../types' +import { renderHook } from '@testing-library/react' +import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types' +import { + createNodeCrudModuleMock, + createUuidModuleMock, +} from '../../__tests__/use-config-test-utils' +import { ComparisonOperator, LogicalOperator } from '../types' +import useConfig from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockGetLoopNodeChildren = vi.hoisted(() => vi.fn()) +const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn()) +const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id')) + +vi.mock('uuid', () => ({ + ...createUuidModuleMock(mockUuid), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { conversationVariables: unknown[], dataSourceList: unknown[] }) => unknown) => selector({ + conversationVariables: [], + dataSourceList: [], + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: [] }), + useAllCustomTools: () => ({ data: [] }), + useAllWorkflowTools: () => ({ data: [] }), + useAllMCPTools: () => ({ data: [] }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useIsChatMode: () => false, + useWorkflow: () => ({ + getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ + toNodeOutputVars: () => [{ nodeId: 'child-node', title: 'Child', vars: [] }], +})) + +vi.mock('../use-is-var-file-attribute', () => ({ + __esModule: true, + default: () => ({ + getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args), + }), +})) + +const createPayload = (overrides: Partial = {}): LoopNodeType => ({ + title: 'Loop', + desc: '', + type: BlockEnum.Loop, + start_node_id: 'start-node', + loop_id: 'loop-node', + logical_operator: LogicalOperator.and, + break_conditions: [{ + id: 'condition-1', + varType: VarType.string, + variable_selector: ['node-1', 'answer'], + comparison_operator: ComparisonOperator.contains, + value: 'hello', + }], + loop_count: 3, + error_handle_mode: ErrorHandleMode.ContinueOnError, + loop_variables: [{ + id: 'loop-var-1', + label: 'item', + var_type: VarType.string, + value_type: ValueType.constant, + value: 'value', + }], + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetLoopNodeChildren.mockReturnValue([]) + mockGetIsVarFileAttribute.mockReturnValue(false) + }) + + it('should expose derived outputs and input variable filtering', () => { + const { result } = renderHook(() => useConfig('loop-node', createPayload())) + + expect(result.current.readOnly).toBe(false) + expect(result.current.childrenNodeVars).toEqual([{ nodeId: 'child-node', title: 'Child', vars: [] }]) + expect(result.current.loopChildrenNodes).toHaveLength(1) + expect(result.current.filterInputVar({ type: VarType.arrayNumber } as never)).toBe(true) + expect(result.current.filterInputVar({ type: VarType.string } as never)).toBe(false) + }) + + it('should update error mode, break conditions and logical operators', () => { + const { result } = renderHook(() => useConfig('loop-node', createPayload())) + + result.current.changeErrorResponseMode({ value: ErrorHandleMode.Terminated }) + result.current.handleAddCondition(['node-1', 'score'], { type: VarType.number } as never) + result.current.handleUpdateCondition('condition-1', { + id: 'condition-1', + varType: VarType.number, + variable_selector: ['node-1', 'score'], + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }) + result.current.handleRemoveCondition('condition-1') + result.current.handleToggleConditionLogicalOperator() + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + error_handle_mode: ErrorHandleMode.Terminated, + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + break_conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + variable_selector: ['node-1', 'score'], + varType: VarType.number, + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + break_conditions: expect.arrayContaining([ + expect.objectContaining({ + varType: VarType.number, + comparison_operator: ComparisonOperator.largerThan, + value: '3', + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + logical_operator: LogicalOperator.or, + })) + }) + + it('should manage sub-variable conditions and loop variables', () => { + const payload = createPayload({ + break_conditions: [{ + id: 'condition-1', + varType: VarType.file, + variable_selector: ['node-1', 'files'], + comparison_operator: ComparisonOperator.contains, + value: '', + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [{ + id: 'sub-1', + key: 'name', + varType: VarType.string, + comparison_operator: ComparisonOperator.contains, + value: '', + }], + }, + }], + }) + const { result } = renderHook(() => useConfig('loop-node', payload)) + + result.current.handleAddSubVariableCondition('condition-1', 'name') + result.current.handleUpdateSubVariableCondition('condition-1', 'sub-1', { + id: 'sub-1', + key: 'size', + varType: VarType.string, + comparison_operator: ComparisonOperator.contains, + value: '2', + }) + result.current.handleRemoveSubVariableCondition('condition-1', 'sub-1') + result.current.handleToggleSubVariableConditionLogicalOperator('condition-1') + result.current.handleUpdateLoopCount(5) + result.current.handleAddLoopVariable() + result.current.handleRemoveLoopVariable('loop-var-1') + result.current.handleUpdateLoopVariable('loop-var-1', { label: 'updated' }) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + break_conditions: [ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + key: 'name', + }), + ]), + }), + }), + ], + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + break_conditions: [ + expect.objectContaining({ + sub_variable_condition: expect.objectContaining({ + logical_operator: LogicalOperator.or, + }), + }), + ], + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + loop_count: 5, + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + loop_variables: expect.arrayContaining([ + expect.objectContaining({ + id: 'generated-id', + value_type: ValueType.constant, + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + loop_variables: [ + expect.objectContaining({ + id: 'generated-id', + value_type: ValueType.constant, + }), + ], + })) + }) +}) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts new file mode 100644 index 0000000000..283b7a2f48 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.helpers.spec.ts @@ -0,0 +1,100 @@ +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { + buildLoopChildCopy, + getContainerBounds, + getContainerResize, + getLoopChildren, + getRestrictedLoopPosition, +} from '../use-interactions.helpers' + +const createNode = (overrides: Record = {}) => ({ + id: 'node', + type: 'custom', + position: { x: 0, y: 0 }, + width: 100, + height: 80, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + ...overrides, +}) + +describe('loop interaction helpers', () => { + it('calculates bounds and container resize from overflowing children', () => { + const children = [ + createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }), + createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }), + ] + + const bounds = getContainerBounds(children as Node[]) + expect(bounds.rightNode?.id).toBe('b') + expect(bounds.bottomNode?.id).toBe('b') + expect(getContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({ + width: 186, + height: 110, + }) + expect(getContainerResize(createNode({ width: 300, height: 300 }), bounds)).toEqual({ + width: undefined, + height: undefined, + }) + }) + + it('restricts loop positions only for loop children and filters loop-start nodes', () => { + const parent = createNode({ id: 'parent', width: 200, height: 180 }) + expect(getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node)).toEqual({ x: undefined, y: undefined }) + expect(getRestrictedLoopPosition( + createNode({ + position: { x: -10, y: 160 }, + width: 80, + height: 40, + data: { isInLoop: true }, + }), + parent as Node, + )).toEqual({ x: 16, y: 120 }) + expect(getRestrictedLoopPosition( + createNode({ + position: { x: 180, y: -4 }, + width: 40, + height: 30, + data: { isInLoop: true }, + }), + parent as Node, + )).toEqual({ x: 144, y: 65 }) + expect(getLoopChildren([ + createNode({ id: 'child', parentId: 'loop-1' }), + createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }), + createNode({ id: 'other', parentId: 'other-loop' }), + ] as Node[], 'loop-1').map(item => item.id)).toEqual(['child']) + }) + + it('builds copied loop children with derived title and loop metadata', () => { + const child = createNode({ + id: 'child', + position: { x: 12, y: 24 }, + positionAbsolute: { x: 12, y: 24 }, + extent: 'parent', + data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true }, + }) + + const result = buildLoopChildCopy({ + child: child as Node, + childNodeType: BlockEnum.Code, + defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'], + nodesWithSameTypeCount: 2, + newNodeId: 'loop-2', + index: 3, + }) + + expect(result.newId).toBe('loop-23') + expect(result.params).toEqual(expect.objectContaining({ + parentId: 'loop-2', + zIndex: 1002, + data: expect.objectContaining({ + title: 'Code 3', + isInLoop: true, + loop_id: 'loop-2', + selected: false, + _isBundled: false, + }), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx new file mode 100644 index 0000000000..ac7da5bf2c --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx @@ -0,0 +1,174 @@ +import type { Node } from '@/app/components/workflow/types' +import { renderHook } from '@testing-library/react' +import { + createLoopNode, + createNode, +} from '@/app/components/workflow/__tests__/fixtures' +import { LOOP_PADDING } from '@/app/components/workflow/constants' +import { BlockEnum } from '@/app/components/workflow/types' +import { useNodeLoopInteractions } from '../use-interactions' + +const mockGetNodes = vi.hoisted(() => vi.fn()) +const mockSetNodes = vi.hoisted(() => vi.fn()) +const mockGenerateNewNode = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', async () => { + const actual = await vi.importActual('reactflow') + return { + ...actual, + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + }), + }), + } +}) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Code]: { + defaultValue: { + title: 'Code', + }, + }, + }, + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args), + getNodeCustomTypeByNodeDataType: () => 'custom', +})) + +describe('useNodeLoopInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expand the loop node when children overflow the bounds', () => { + mockGetNodes.mockReturnValue([ + createLoopNode({ + id: 'loop-node', + width: 120, + height: 80, + data: { width: 120, height: 80 }, + }), + createNode({ + id: 'child-node', + parentId: 'loop-node', + position: { x: 100, y: 90 }, + width: 60, + height: 40, + }), + ]) + + const { result } = renderHook(() => useNodeLoopInteractions()) + result.current.handleNodeLoopRerender('loop-node') + + expect(mockSetNodes).toHaveBeenCalledTimes(1) + const updatedNodes = mockSetNodes.mock.calls[0][0] + const updatedLoopNode = updatedNodes.find((node: Node) => node.id === 'loop-node') + expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right) + expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom) + }) + + it('should restrict dragging to the loop container padding', () => { + mockGetNodes.mockReturnValue([ + createLoopNode({ + id: 'loop-node', + width: 200, + height: 180, + data: { width: 200, height: 180 }, + }), + ]) + + const { result } = renderHook(() => useNodeLoopInteractions()) + const dragResult = result.current.handleNodeLoopChildDrag(createNode({ + id: 'child-node', + parentId: 'loop-node', + position: { x: -10, y: -5 }, + width: 80, + height: 60, + data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true }, + })) + + expect(dragResult.restrictPosition).toEqual({ + x: LOOP_PADDING.left, + y: LOOP_PADDING.top, + }) + }) + + it('should rerender the parent loop node when a child size changes', () => { + mockGetNodes.mockReturnValue([ + createLoopNode({ + id: 'loop-node', + width: 120, + height: 80, + data: { width: 120, height: 80 }, + }), + createNode({ + id: 'child-node', + parentId: 'loop-node', + position: { x: 100, y: 90 }, + width: 60, + height: 40, + }), + ]) + + const { result } = renderHook(() => useNodeLoopInteractions()) + result.current.handleNodeLoopChildSizeChange('child-node') + + expect(mockSetNodes).toHaveBeenCalledTimes(1) + }) + + it('should skip loop rerender when the resized node has no parent', () => { + mockGetNodes.mockReturnValue([ + createNode({ + id: 'standalone-node', + data: { type: BlockEnum.Code, title: 'Standalone', desc: '' }, + }), + ]) + + const { result } = renderHook(() => useNodeLoopInteractions()) + result.current.handleNodeLoopChildSizeChange('standalone-node') + + expect(mockSetNodes).not.toHaveBeenCalled() + }) + + it('should copy loop children and remap ids', () => { + mockGetNodes.mockReturnValue([ + createLoopNode({ id: 'loop-node' }), + createNode({ + id: 'child-node', + parentId: 'loop-node', + data: { type: BlockEnum.Code, title: 'Child', desc: '' }, + }), + createNode({ + id: 'same-type-node', + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + }), + ]) + mockGenerateNewNode.mockReturnValue({ + newNode: createNode({ + id: 'generated', + parentId: 'new-loop', + data: { type: BlockEnum.Code, title: 'Code 3', desc: '', isInLoop: true, loop_id: 'new-loop' }, + }), + }) + + const { result } = renderHook(() => useNodeLoopInteractions()) + const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { existing: 'mapped' }) + + expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({ + type: 'custom', + parentId: 'new-loop', + })) + expect(copyResult.copyChildren).toHaveLength(1) + expect(copyResult.newIdMapping).toEqual({ + 'existing': 'mapped', + 'child-node': 'new-loopgeneratednew-loop0', + }) + }) +}) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts new file mode 100644 index 0000000000..7d8438b07c --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.helpers.spec.ts @@ -0,0 +1,241 @@ +import type { InputVar, Node, Variable } from '../../../types' +import type { Condition } from '../types' +import { BlockEnum, InputVarType, ValueType, VarType } from '@/app/components/workflow/types' +import { VALUE_SELECTOR_DELIMITER } from '@/config' +import { ComparisonOperator, LogicalOperator } from '../types' +import { + buildUsedOutVars, + createInputVarValues, + dedupeInputVars, + getDependentVarsFromLoopPayload, + getVarSelectorsFromCase, + getVarSelectorsFromCondition, +} from '../use-single-run-form-params.helpers' + +const mockGetNodeInfoById = vi.hoisted(() => vi.fn()) +const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn()) +const mockGetNodeUsedVars = vi.hoisted(() => vi.fn()) +const mockIsSystemVar = vi.hoisted(() => vi.fn()) + +vi.mock('../../_base/components/variable/utils', () => ({ + getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args), + getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args), + getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args), + isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args), +})) + +const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ + id, + position: { x: 0, y: 0 }, + data: { + title, + desc: '', + type, + }, +} as Node) + +const createInputVar = (variable: string, label: InputVar['label'] = variable): InputVar => ({ + type: InputVarType.textInput, + label, + variable, + required: false, +}) + +const createCondition = (overrides: Partial = {}): Condition => ({ + id: 'condition-1', + varType: VarType.string, + variable_selector: ['tool-node', 'value'], + comparison_operator: ComparisonOperator.equal, + value: '', + ...overrides, +}) + +describe('use-single-run-form-params helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should collect var selectors from conditions and nested cases', () => { + const nestedCondition = createCondition({ + variable_selector: ['tool-node', 'value'], + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [ + createCondition({ + id: 'sub-condition-1', + variable_selector: ['start-node', 'answer'], + }), + ], + }, + }) + + expect(getVarSelectorsFromCondition(nestedCondition)).toEqual([ + ['tool-node', 'value'], + ['start-node', 'answer'], + ]) + expect(getVarSelectorsFromCase({ + logical_operator: LogicalOperator.or, + conditions: [ + nestedCondition, + createCondition({ + id: 'condition-2', + variable_selector: ['other-node', 'result'], + }), + ], + })).toEqual([ + ['tool-node', 'value'], + ['start-node', 'answer'], + ['other-node', 'result'], + ]) + }) + + it('should copy input values and dedupe duplicate or invalid input vars', () => { + const source = { + question: 'hello', + retry: true, + } + + const values = createInputVarValues(source) + const deduped = dedupeInputVars([ + createInputVar('tool-node.value'), + createInputVar('tool-node.value'), + undefined as unknown as InputVar, + createInputVar('start-node.answer'), + ]) + + expect(values).toEqual(source) + expect(values).not.toBe(source) + expect(deduped).toEqual([ + createInputVar('tool-node.value'), + createInputVar('start-node.answer'), + ]) + }) + + it('should build used output vars and pass-to-server keys while filtering loop-local selectors', () => { + const startNode = createNode('start-node', 'Start Node', BlockEnum.Start) + const sysNode = createNode('sys', 'System', BlockEnum.Start) + const loopChildrenNodes = [ + createNode('tool-a', 'Tool A'), + createNode('tool-b', 'Tool B'), + createNode('current-node', 'Current Node'), + createNode('inner-node', 'Inner Node'), + ] + + mockGetNodeUsedVars.mockImplementation((node: Node) => { + switch (node.id) { + case 'tool-a': + return [['sys', 'files']] + case 'tool-b': + return [['start-node', 'answer'], ['current-node', 'self'], ['inner-node', 'secret']] + default: + return [] + } + }) + mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => { + return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key' + }) + mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id)) + mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys') + + const toVarInputs = vi.fn((variables: Variable[]) => variables.map(variable => createInputVar( + variable.variable, + variable.label as InputVar['label'], + ))) + + const result = buildUsedOutVars({ + loopChildrenNodes, + currentNodeId: 'current-node', + canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes], + isNodeInLoop: nodeId => nodeId === 'inner-node', + toVarInputs, + }) + + expect(toVarInputs).toHaveBeenCalledWith([ + expect.objectContaining({ + variable: 'sys.files', + label: { + nodeType: BlockEnum.Start, + nodeName: 'System', + variable: 'sys.files', + }, + }), + expect.objectContaining({ + variable: 'start-node.answer', + label: { + nodeType: BlockEnum.Start, + nodeName: 'Start Node', + variable: 'answer', + }, + }), + ]) + expect(result.usedOutVars).toEqual([ + createInputVar('sys.files', { + nodeType: BlockEnum.Start, + nodeName: 'System', + variable: 'sys.files', + }), + createInputVar('start-node.answer', { + nodeType: BlockEnum.Start, + nodeName: 'Start Node', + variable: 'answer', + }), + ]) + expect(result.allVarObject).toEqual({ + [['sys.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: { + inSingleRunPassedKey: 'sys_files', + }, + [['sys.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: { + inSingleRunPassedKey: 'sys_files_backup', + }, + [['start-node.answer', 'tool-b', 0].join(VALUE_SELECTOR_DELIMITER)]: { + inSingleRunPassedKey: 'answer_key', + }, + }) + }) + + it('should derive dependent vars from payload and filter current node references', () => { + const dependentVars = getDependentVarsFromLoopPayload({ + nodeId: 'loop-node', + usedOutVars: [ + createInputVar('start-node.answer'), + createInputVar('loop-node.internal'), + ], + breakConditions: [ + createCondition({ + variable_selector: ['tool-node', 'value'], + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [ + createCondition({ + id: 'sub-condition-1', + variable_selector: ['loop-node', 'ignored'], + }), + ], + }, + }), + ], + loopVariables: [ + { + id: 'loop-variable-1', + label: 'Loop Input', + var_type: VarType.string, + value_type: ValueType.variable, + value: ['tool-node', 'next'], + }, + { + id: 'loop-variable-2', + label: 'Constant', + var_type: VarType.string, + value_type: ValueType.constant, + value: 'plain-text', + }, + ], + }) + + expect(dependentVars).toEqual([ + ['start-node', 'answer'], + ['tool-node', 'value'], + ['tool-node', 'next'], + ]) + }) +}) diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts new file mode 100644 index 0000000000..7ff89492cd --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts @@ -0,0 +1,216 @@ +import type { InputVar, Node } from '../../../types' +import type { LoopNodeType } from '../types' +import type { NodeTracing } from '@/types/workflow' +import { act, renderHook } from '@testing-library/react' +import { BlockEnum, ErrorHandleMode, InputVarType, ValueType, VarType } from '@/app/components/workflow/types' +import { ComparisonOperator, LogicalOperator } from '../types' +import useSingleRunFormParams from '../use-single-run-form-params' + +const mockUseIsNodeInLoop = vi.hoisted(() => vi.fn()) +const mockUseWorkflow = vi.hoisted(() => vi.fn()) +const mockFormatTracing = vi.hoisted(() => vi.fn()) +const mockGetNodeUsedVars = vi.hoisted(() => vi.fn()) +const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn()) +const mockGetNodeInfoById = vi.hoisted(() => vi.fn()) +const mockIsSystemVar = vi.hoisted(() => vi.fn()) + +vi.mock('../../../hooks', () => ({ + useIsNodeInLoop: (...args: unknown[]) => mockUseIsNodeInLoop(...args), + useWorkflow: () => mockUseWorkflow(), +})) + +vi.mock('@/app/components/workflow/run/utils/format-log', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockFormatTracing(...args), +})) + +vi.mock('../../_base/components/variable/utils', () => ({ + getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args), + getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args), + getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args), + isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args), +})) + +const createLoopNode = (overrides: Partial = {}): LoopNodeType => ({ + title: 'Loop', + desc: '', + type: BlockEnum.Loop, + start_node_id: 'start-node', + loop_count: 3, + error_handle_mode: ErrorHandleMode.Terminated, + break_conditions: [], + loop_variables: [], + ...overrides, +}) + +const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ + id, + position: { x: 0, y: 0 }, + data: { + title, + type, + desc: '', + }, +} as Node) + +const createInputVar = (variable: string): InputVar => ({ + type: InputVarType.textInput, + label: variable, + variable, + required: false, +}) + +const createRunTrace = (): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'loop-node', + node_type: BlockEnum.Loop, + title: 'Loop', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs_truncated: false, + status: 'succeeded', + elapsed_time: 1, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 2, + loop_index: 1, + }, + created_at: 0, + created_by: { + id: 'user-1', + name: 'User', + email: 'user@example.com', + }, + finished_at: 1, +}) + +describe('useSingleRunFormParams', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseIsNodeInLoop.mockReturnValue({ + isNodeInLoop: (nodeId: string) => nodeId === 'inner-node', + }) + mockUseWorkflow.mockReturnValue({ + getLoopNodeChildren: () => [ + createVariableNode('tool-a', 'Tool A'), + createVariableNode('loop-node', 'Loop Node'), + createVariableNode('inner-node', 'Inner Node'), + ], + getBeforeNodesInSameBranch: () => [ + createVariableNode('start-node', 'Start Node', BlockEnum.Start), + ], + }) + mockGetNodeUsedVars.mockImplementation((node: Node) => { + if (node.id === 'tool-a') + return [['start-node', 'answer']] + if (node.id === 'loop-node') + return [['loop-node', 'item']] + if (node.id === 'inner-node') + return [['inner-node', 'secret']] + return [] + }) + mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key') + mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id)) + mockIsSystemVar.mockReturnValue(false) + mockFormatTracing.mockReturnValue([{ + id: 'formatted-node', + execution_metadata: { loop_index: 9 }, + }]) + }) + + it('should build single-run forms and filter out loop-local variables', () => { + const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => variables.map(item => createInputVar(item.variable))) + const varSelectorsToVarInputs = vi.fn(() => [ + createInputVar('tool-a.result'), + createInputVar('tool-a.result'), + createInputVar('start-node.answer'), + ]) + + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'loop-node', + payload: createLoopNode({ + break_conditions: [{ + id: 'condition-1', + varType: VarType.string, + variable_selector: ['tool-a', 'result'], + comparison_operator: ComparisonOperator.equal, + value: '', + sub_variable_condition: { + logical_operator: LogicalOperator.and, + conditions: [], + }, + }], + loop_variables: [{ + id: 'loop-variable-1', + label: 'Loop Value', + var_type: VarType.string, + value_type: ValueType.variable, + value: ['start-node', 'answer'], + }], + }), + runInputData: { + question: 'hello', + }, + runResult: null as unknown as NodeTracing, + loopRunResult: [], + setRunInputData: vi.fn(), + toVarInputs, + varSelectorsToVarInputs, + })) + + expect(toVarInputs).toHaveBeenCalledWith([ + expect.objectContaining({ variable: 'start-node.answer' }), + ]) + expect(result.current.forms).toHaveLength(1) + expect(result.current.forms[0].inputs).toEqual([ + createInputVar('start-node.answer'), + createInputVar('tool-a.result'), + createInputVar('start-node.answer'), + ]) + expect(result.current.forms[0].values).toEqual({ question: 'hello' }) + expect(result.current.allVarObject).toEqual({ + 'start-node.answer@@@tool-a@@@0': { + inSingleRunPassedKey: 'passed_key', + }, + }) + expect(result.current.getDependentVars()).toEqual([ + ['start-node', 'answer'], + ['tool-a', 'result'], + ['start-node', 'answer'], + ]) + }) + + it('should forward onChange and merge tracing metadata into node info', () => { + const setRunInputData = vi.fn() + const runResult = createRunTrace() + + const { result } = renderHook(() => useSingleRunFormParams({ + id: 'loop-node', + payload: createLoopNode(), + runInputData: {}, + runResult, + loopRunResult: [runResult], + setRunInputData, + toVarInputs: vi.fn(() => []), + varSelectorsToVarInputs: vi.fn(() => []), + })) + + act(() => { + result.current.forms[0].onChange({ retry: true }) + }) + + expect(setRunInputData).toHaveBeenCalledWith({ retry: true }) + expect(mockFormatTracing).toHaveBeenCalledWith([runResult], expect.any(Function)) + expect(result.current.nodeInfo).toEqual({ + id: 'formatted-node', + execution_metadata: expect.objectContaining({ + loop_index: 9, + }), + }) + }) +}) diff --git a/web/app/components/workflow/nodes/loop/use-config.helpers.ts b/web/app/components/workflow/nodes/loop/use-config.helpers.ts new file mode 100644 index 0000000000..edeca0b766 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/use-config.helpers.ts @@ -0,0 +1,171 @@ +import type { ErrorHandleMode, Var } from '../../types' +import type { Condition, LoopNodeType, LoopVariable } from './types' +import { produce } from 'immer' +import { v4 as uuid4 } from 'uuid' +import { ValueType, VarType } from '../../types' +import { LogicalOperator } from './types' +import { getOperators } from './utils' + +export const canUseAsLoopInput = (variable: Var) => { + return [ + VarType.array, + VarType.arrayString, + VarType.arrayNumber, + VarType.arrayObject, + VarType.arrayFile, + ].includes(variable.type) +} + +export const updateErrorHandleMode = ( + inputs: LoopNodeType, + mode: ErrorHandleMode, +) => produce(inputs, (draft) => { + draft.error_handle_mode = mode +}) + +export const addBreakCondition = ({ + inputs, + valueSelector, + variable, + isVarFileAttribute, +}: { + inputs: LoopNodeType + valueSelector: string[] + variable: { type: VarType } + isVarFileAttribute: boolean +}) => produce(inputs, (draft) => { + if (!draft.break_conditions) + draft.break_conditions = [] + + draft.break_conditions.push({ + id: uuid4(), + varType: variable.type, + variable_selector: valueSelector, + comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0], + value: variable.type === VarType.boolean ? 'false' : '', + }) +}) + +export const removeBreakCondition = ( + inputs: LoopNodeType, + conditionId: string, +) => produce(inputs, (draft) => { + draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId) +}) + +export const updateBreakCondition = ( + inputs: LoopNodeType, + conditionId: string, + condition: Condition, +) => produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) + if (targetCondition) + Object.assign(targetCondition, condition) +}) + +export const toggleConditionOperator = (inputs: LoopNodeType) => produce(inputs, (draft) => { + draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and +}) + +export const addSubVariableCondition = ( + inputs: LoopNodeType, + conditionId: string, + key?: string, +) => produce(inputs, (draft) => { + const condition = draft.break_conditions?.find(item => item.id === conditionId) + if (!condition) + return + + if (!condition.sub_variable_condition) { + condition.sub_variable_condition = { + logical_operator: LogicalOperator.and, + conditions: [], + } + } + + const comparisonOperators = getOperators(VarType.string, { key: key || '' }) + condition.sub_variable_condition.conditions.push({ + id: uuid4(), + key: key || '', + varType: VarType.string, + comparison_operator: comparisonOperators[0], + value: '', + }) +}) + +export const removeSubVariableCondition = ( + inputs: LoopNodeType, + conditionId: string, + subConditionId: string, +) => produce(inputs, (draft) => { + const condition = draft.break_conditions?.find(item => item.id === conditionId) + if (!condition?.sub_variable_condition) + return + + condition.sub_variable_condition.conditions = condition.sub_variable_condition.conditions + .filter(item => item.id !== subConditionId) +}) + +export const updateSubVariableCondition = ( + inputs: LoopNodeType, + conditionId: string, + subConditionId: string, + condition: Condition, +) => produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) + const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find(item => item.id === subConditionId) + if (targetSubCondition) + Object.assign(targetSubCondition, condition) +}) + +export const toggleSubVariableConditionOperator = ( + inputs: LoopNodeType, + conditionId: string, +) => produce(inputs, (draft) => { + const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) + if (targetCondition?.sub_variable_condition) { + targetCondition.sub_variable_condition.logical_operator + = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and + } +}) + +export const updateLoopCount = ( + inputs: LoopNodeType, + value: number, +) => produce(inputs, (draft) => { + draft.loop_count = value +}) + +export const addLoopVariable = (inputs: LoopNodeType) => produce(inputs, (draft) => { + if (!draft.loop_variables) + draft.loop_variables = [] + + draft.loop_variables.push({ + id: uuid4(), + label: '', + var_type: VarType.string, + value_type: ValueType.constant, + value: '', + }) +}) + +export const removeLoopVariable = ( + inputs: LoopNodeType, + id: string, +) => produce(inputs, (draft) => { + draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id) +}) + +export const updateLoopVariable = ( + inputs: LoopNodeType, + id: string, + updateData: Partial, +) => produce(inputs, (draft) => { + const index = draft.loop_variables?.findIndex(item => item.id === id) ?? -1 + if (index > -1) { + draft.loop_variables![index] = { + ...draft.loop_variables![index], + ...updateData, + } + } +}) diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index d64cd8492e..8c39cfbfc6 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -9,12 +9,10 @@ import type { HandleUpdateSubVariableCondition, LoopNodeType, } from './types' -import { produce } from 'immer' import { useCallback, useRef, } from 'react' -import { v4 as uuid4 } from 'uuid' import { useStore } from '@/app/components/workflow/store' import { useAllBuiltInTools, @@ -27,12 +25,25 @@ import { useNodesReadOnly, useWorkflow, } from '../../hooks' -import { ValueType, VarType } from '../../types' import { toNodeOutputVars } from '../_base/components/variable/utils' import useNodeCrud from '../_base/hooks/use-node-crud' -import { LogicalOperator } from './types' +import { + addBreakCondition, + addLoopVariable, + addSubVariableCondition, + canUseAsLoopInput, + removeBreakCondition, + removeLoopVariable, + removeSubVariableCondition, + toggleConditionOperator, + toggleSubVariableConditionOperator, + updateBreakCondition, + updateErrorHandleMode, + updateLoopCount, + updateLoopVariable, + updateSubVariableCondition, +} from './use-config.helpers' import useIsVarFileAttribute from './use-is-var-file-attribute' -import { getOperators } from './utils' const useConfig = (id: string, payload: LoopNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -46,9 +57,7 @@ const useConfig = (id: string, payload: LoopNodeType) => { setInputs(newInputs) }, [setInputs]) - const filterInputVar = useCallback((varPayload: Var) => { - return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type) - }, []) + const filterInputVar = useCallback((varPayload: Var) => canUseAsLoopInput(varPayload), []) // output const { getLoopNodeChildren } = useWorkflow() @@ -74,158 +83,60 @@ const useConfig = (id: string, payload: LoopNodeType) => { }) const changeErrorResponseMode = useCallback((item: { value: unknown }) => { - const newInputs = produce(inputsRef.current, (draft) => { - draft.error_handle_mode = item.value as ErrorHandleMode - }) - handleInputsChange(newInputs) - }, [inputs, handleInputsChange]) + handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode)) + }, [handleInputsChange]) const handleAddCondition = useCallback((valueSelector, varItem) => { - const newInputs = produce(inputsRef.current, (draft) => { - if (!draft.break_conditions) - draft.break_conditions = [] - - draft.break_conditions?.push({ - id: uuid4(), - varType: varItem.type, - variable_selector: valueSelector, - comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0], - value: varItem.type === VarType.boolean ? 'false' : '', - }) - }) - handleInputsChange(newInputs) + handleInputsChange(addBreakCondition({ + inputs: inputsRef.current, + valueSelector, + variable: varItem, + isVarFileAttribute: !!getIsVarFileAttribute(valueSelector), + })) }, [getIsVarFileAttribute, handleInputsChange]) const handleRemoveCondition = useCallback((conditionId) => { - const newInputs = produce(inputsRef.current, (draft) => { - draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId) - }) - handleInputsChange(newInputs) + handleInputsChange(removeBreakCondition(inputsRef.current, conditionId)) }, [handleInputsChange]) const handleUpdateCondition = useCallback((conditionId, newCondition) => { - const newInputs = produce(inputsRef.current, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - if (targetCondition) - Object.assign(targetCondition, newCondition) - }) - handleInputsChange(newInputs) + handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition)) }, [handleInputsChange]) const handleToggleConditionLogicalOperator = useCallback(() => { - const newInputs = produce(inputsRef.current, (draft) => { - draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - }) - handleInputsChange(newInputs) + handleInputsChange(toggleConditionOperator(inputsRef.current)) }, [handleInputsChange]) const handleAddSubVariableCondition = useCallback((conditionId: string, key?: string) => { - const newInputs = produce(inputsRef.current, (draft) => { - const condition = draft.break_conditions?.find(item => item.id === conditionId) - if (!condition) - return - if (!condition?.sub_variable_condition) { - condition.sub_variable_condition = { - logical_operator: LogicalOperator.and, - conditions: [], - } - } - const subVarCondition = condition.sub_variable_condition - if (subVarCondition) { - if (!subVarCondition.conditions) - subVarCondition.conditions = [] - - const svcComparisonOperators = getOperators(VarType.string, { key: key || '' }) - - subVarCondition.conditions.push({ - id: uuid4(), - key: key || '', - varType: VarType.string, - comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined, - value: '', - }) - } - }) - handleInputsChange(newInputs) + handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key)) }, [handleInputsChange]) const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => { - const newInputs = produce(inputsRef.current, (draft) => { - const condition = draft.break_conditions?.find(item => item.id === conditionId) - if (!condition) - return - if (!condition?.sub_variable_condition) - return - const subVarCondition = condition.sub_variable_condition - if (subVarCondition) - subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId) - }) - handleInputsChange(newInputs) + handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId)) }, [handleInputsChange]) const handleUpdateSubVariableCondition = useCallback((conditionId, subConditionId, newSubCondition) => { - const newInputs = produce(inputsRef.current, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - if (targetCondition && targetCondition.sub_variable_condition) { - const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId) - if (targetSubCondition) - Object.assign(targetSubCondition, newSubCondition) - } - }) - handleInputsChange(newInputs) + handleInputsChange(updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition)) }, [handleInputsChange]) const handleToggleSubVariableConditionLogicalOperator = useCallback((conditionId) => { - const newInputs = produce(inputsRef.current, (draft) => { - const targetCondition = draft.break_conditions?.find(item => item.id === conditionId) - if (targetCondition && targetCondition.sub_variable_condition) - targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and - }) - handleInputsChange(newInputs) + handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId)) }, [handleInputsChange]) const handleUpdateLoopCount = useCallback((value: number) => { - const newInputs = produce(inputsRef.current, (draft) => { - draft.loop_count = value - }) - handleInputsChange(newInputs) + handleInputsChange(updateLoopCount(inputsRef.current, value)) }, [handleInputsChange]) const handleAddLoopVariable = useCallback(() => { - const newInputs = produce(inputsRef.current, (draft) => { - if (!draft.loop_variables) - draft.loop_variables = [] - - draft.loop_variables.push({ - id: uuid4(), - label: '', - var_type: VarType.string, - value_type: ValueType.constant, - value: '', - }) - }) - handleInputsChange(newInputs) + handleInputsChange(addLoopVariable(inputsRef.current)) }, [handleInputsChange]) const handleRemoveLoopVariable = useCallback((id: string) => { - const newInputs = produce(inputsRef.current, (draft) => { - draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id) - }) - handleInputsChange(newInputs) + handleInputsChange(removeLoopVariable(inputsRef.current, id)) }, [handleInputsChange]) const handleUpdateLoopVariable = useCallback((id: string, updateData: any) => { - const loopVariables = inputsRef.current.loop_variables || [] - const index = loopVariables.findIndex(item => item.id === id) - const newInputs = produce(inputsRef.current, (draft) => { - if (index > -1) { - draft.loop_variables![index] = { - ...draft.loop_variables![index], - ...updateData, - } - } - }) - handleInputsChange(newInputs) + handleInputsChange(updateLoopVariable(inputsRef.current, id, updateData)) }, [handleInputsChange]) return { diff --git a/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts b/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts new file mode 100644 index 0000000000..41fa7de8f2 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/use-interactions.helpers.ts @@ -0,0 +1,109 @@ +import type { + BlockEnum, + Node, +} from '../../types' +import { + LOOP_CHILDREN_Z_INDEX, + LOOP_PADDING, +} from '../../constants' +import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants' + +type ContainerBounds = { + rightNode?: Node + bottomNode?: Node +} + +export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => { + return childrenNodes.reduce((acc, node) => { + const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width! + ? node + : acc.rightNode + const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height! + ? node + : acc.bottomNode + + return { + rightNode: nextRightNode, + bottomNode: nextBottomNode, + } + }, {}) +} + +export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) => { + const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width! + ? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right + : undefined + const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height! + ? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom + : undefined + + return { + width, + height, + } +} + +export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => { + const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } + + if (!node.data.isInLoop || !parentNode) + return restrictPosition + + if (node.position.y < LOOP_PADDING.top) + restrictPosition.y = LOOP_PADDING.top + if (node.position.x < LOOP_PADDING.left) + restrictPosition.x = LOOP_PADDING.left + if (node.position.x + node.width! > parentNode.width! - LOOP_PADDING.right) + restrictPosition.x = parentNode.width! - LOOP_PADDING.right - node.width! + if (node.position.y + node.height! > parentNode.height! - LOOP_PADDING.bottom) + restrictPosition.y = parentNode.height! - LOOP_PADDING.bottom - node.height! + + return restrictPosition +} + +export const getLoopChildren = (nodes: Node[], nodeId: string) => { + return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE) +} + +export const buildLoopChildCopy = ({ + child, + childNodeType, + defaultValue, + nodesWithSameTypeCount, + newNodeId, + index, +}: { + child: Node + childNodeType: BlockEnum + defaultValue: Node['data'] + nodesWithSameTypeCount: number + newNodeId: string + index: number +}) => { + const params = { + type: child.type!, + data: { + ...defaultValue, + ...child.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + title: nodesWithSameTypeCount > 0 ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` : defaultValue.title, + isInLoop: true, + loop_id: newNodeId, + type: childNodeType, + }, + position: child.position, + positionAbsolute: child.positionAbsolute, + parentId: newNodeId, + extent: child.extent, + zIndex: LOOP_CHILDREN_Z_INDEX, + } + + return { + params, + newId: `${newNodeId}${index}`, + } +} diff --git a/web/app/components/workflow/nodes/loop/use-interactions.ts b/web/app/components/workflow/nodes/loop/use-interactions.ts index e9c4e31e30..5b01460778 100644 --- a/web/app/components/workflow/nodes/loop/use-interactions.ts +++ b/web/app/components/workflow/nodes/loop/use-interactions.ts @@ -6,15 +6,17 @@ import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useNodesMetaData } from '@/app/components/workflow/hooks' -import { - LOOP_CHILDREN_Z_INDEX, - LOOP_PADDING, -} from '../../constants' import { generateNewNode, getNodeCustomTypeByNodeDataType, } from '../../utils' -import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants' +import { + buildLoopChildCopy, + getContainerBounds, + getContainerResize, + getLoopChildren, + getRestrictedLoopPosition, +} from './use-interactions.helpers' export const useNodeLoopInteractions = () => { const store = useStoreApi() @@ -29,40 +31,19 @@ export const useNodeLoopInteractions = () => { const nodes = getNodes() const currentNode = nodes.find(n => n.id === nodeId)! const childrenNodes = nodes.filter(n => n.parentId === nodeId) - let rightNode: Node - let bottomNode: Node + const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes)) - childrenNodes.forEach((n) => { - if (rightNode) { - if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) - rightNode = n - } - else { - rightNode = n - } - if (bottomNode) { - if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!) - bottomNode = n - } - else { - bottomNode = n - } - }) - - const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width! - const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height! - - if (widthShouldExtend || heightShouldExtend) { + if (resize.width || resize.height) { const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { if (n.id === nodeId) { - if (widthShouldExtend) { - n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right - n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right + if (resize.width) { + n.data.width = resize.width + n.width = resize.width } - if (heightShouldExtend) { - n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom - n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom + if (resize.height) { + n.data.height = resize.height + n.height = resize.height } } }) @@ -76,25 +57,8 @@ export const useNodeLoopInteractions = () => { const { getNodes } = store.getState() const nodes = getNodes() - const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined } - - if (node.data.isInLoop) { - const parentNode = nodes.find(n => n.id === node.parentId) - - if (parentNode) { - if (node.position.y < LOOP_PADDING.top) - restrictPosition.y = LOOP_PADDING.top - if (node.position.x < LOOP_PADDING.left) - restrictPosition.x = LOOP_PADDING.left - if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right) - restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width! - if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom) - restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height! - } - } - return { - restrictPosition, + restrictPosition: getRestrictedLoopPosition(node, nodes.find(n => n.id === node.parentId)), } }, [store]) @@ -111,35 +75,26 @@ export const useNodeLoopInteractions = () => { const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record) => { const { getNodes } = store.getState() const nodes = getNodes() - const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE) + const childrenNodes = getLoopChildren(nodes, nodeId) const newIdMapping = { ...idMapping } const copyChildren = childrenNodes.map((child, index) => { const childNodeType = child.data.type as BlockEnum const { defaultValue } = nodesMetaDataMap![childNodeType] const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType) - const { newNode } = generateNewNode({ - type: getNodeCustomTypeByNodeDataType(childNodeType), - data: { - ...defaultValue, - ...child.data, - selected: false, - _isBundled: false, - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: [], - _dimmed: false, - title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - isInLoop: true, - loop_id: newNodeId, - type: childNodeType, - }, - position: child.position, - positionAbsolute: child.positionAbsolute, - parentId: newNodeId, - extent: child.extent, - zIndex: LOOP_CHILDREN_Z_INDEX, + const childCopy = buildLoopChildCopy({ + child, + childNodeType, + defaultValue: defaultValue as Node['data'], + nodesWithSameTypeCount: nodesWithSameType.length, + newNodeId, + index, }) - newNode.id = `${newNodeId}${newNode.id + index}` + const { newNode } = generateNewNode({ + ...childCopy.params, + type: getNodeCustomTypeByNodeDataType(childNodeType), + }) + newNode.id = `${newNodeId}${newNode.id + childCopy.newId}` newIdMapping[child.id] = newNode.id return newNode }) diff --git a/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts b/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts new file mode 100644 index 0000000000..02db10c173 --- /dev/null +++ b/web/app/components/workflow/nodes/loop/use-single-run-form-params.helpers.ts @@ -0,0 +1,131 @@ +import type { InputVar, Node, ValueSelector, Variable } from '../../types' +import type { CaseItem, Condition, LoopVariable } from './types' +import { ValueType } from '@/app/components/workflow/types' +import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' +import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils' + +export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] { + const vars: ValueSelector[] = [] + caseItem.conditions?.forEach((condition) => { + vars.push(...getVarSelectorsFromCondition(condition)) + }) + return vars +} + +export function getVarSelectorsFromCondition(condition: Condition): ValueSelector[] { + const vars: ValueSelector[] = [] + if (condition.variable_selector) + vars.push(condition.variable_selector) + + if (condition.sub_variable_condition?.conditions?.length) + vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition)) + + return vars +} + +export const createInputVarValues = (runInputData: Record) => { + const vars: Record = {} + Object.keys(runInputData).forEach((key) => { + vars[key] = runInputData[key] + }) + return vars +} + +export const dedupeInputVars = (inputVars: InputVar[]) => { + const seen: Record = {} + const uniqueInputVars: InputVar[] = [] + + inputVars.forEach((input) => { + if (!input || seen[input.variable]) + return + + seen[input.variable] = true + uniqueInputVars.push(input) + }) + + return uniqueInputVars +} + +export const buildUsedOutVars = ({ + loopChildrenNodes, + currentNodeId, + canChooseVarNodes, + isNodeInLoop, + toVarInputs, +}: { + loopChildrenNodes: Node[] + currentNodeId: string + canChooseVarNodes: Node[] + isNodeInLoop: (nodeId: string) => boolean + toVarInputs: (variables: Variable[]) => InputVar[] +}) => { + const vars: ValueSelector[] = [] + const seenVarSelectors: Record = {} + const allVarObject: Record = {} + + loopChildrenNodes.forEach((node) => { + const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0) + nodeVars.forEach((varSelector) => { + if (varSelector[0] === currentNodeId) + return + if (isNodeInLoop(varSelector[0])) + return + + const varSelectorStr = varSelector.join('.') + if (!seenVarSelectors[varSelectorStr]) { + seenVarSelectors[varSelectorStr] = true + vars.push(varSelector) + } + + let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector) + if (typeof passToServerKeys === 'string') + passToServerKeys = [passToServerKeys] + + passToServerKeys.forEach((key: string, index: number) => { + allVarObject[[varSelectorStr, node.id, index].join(DELIMITER)] = { + inSingleRunPassedKey: key, + } + }) + }) + }) + + const usedOutVars = toVarInputs(vars.map((valueSelector) => { + const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0]) + return { + label: { + nodeType: varInfo?.data.type, + nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, + variable: isSystemVar(valueSelector) ? valueSelector.join('.') : valueSelector[valueSelector.length - 1], + }, + variable: valueSelector.join('.'), + value_selector: valueSelector, + } + })) + + return { usedOutVars, allVarObject } +} + +export const getDependentVarsFromLoopPayload = ({ + nodeId, + usedOutVars, + breakConditions, + loopVariables, +}: { + nodeId: string + usedOutVars: InputVar[] + breakConditions?: Condition[] + loopVariables?: LoopVariable[] +}) => { + const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.')) + + breakConditions?.forEach((condition) => { + vars.push(...getVarSelectorsFromCondition(condition)) + }) + + loopVariables?.forEach((loopVariable) => { + if (loopVariable.value_type === ValueType.variable) + vars.push(loopVariable.value) + }) + + return vars.filter(item => item[0] !== nodeId) +} diff --git a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts index c4123b0e30..5cd0abeb79 100644 --- a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts @@ -1,13 +1,18 @@ import type { InputVar, ValueSelector, Variable } from '../../types' -import type { CaseItem, Condition, LoopNodeType } from './types' +import type { LoopNodeType } from './types' import type { NodeTracing } from '@/types/workflow' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import formatTracing from '@/app/components/workflow/run/utils/format-log' import { ValueType } from '@/app/components/workflow/types' -import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' import { useIsNodeInLoop, useWorkflow } from '../../hooks' -import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils' +import { + buildUsedOutVars, + createInputVarValues, + dedupeInputVars, + getDependentVarsFromLoopPayload, + getVarSelectorsFromCondition, +} from './use-single-run-form-params.helpers' type Params = { id: string @@ -37,58 +42,15 @@ const useSingleRunFormParams = ({ const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow() const loopChildrenNodes = getLoopNodeChildren(id) const beforeNodes = getBeforeNodesInSameBranch(id) - const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes] + const canChooseVarNodes = useMemo(() => [...beforeNodes, ...loopChildrenNodes], [beforeNodes, loopChildrenNodes]) - const { usedOutVars, allVarObject } = (() => { - const vars: ValueSelector[] = [] - const varObjs: Record = {} - const allVarObject: Record = {} - loopChildrenNodes.forEach((node) => { - const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0) - nodeVars.forEach((varSelector) => { - if (varSelector[0] === id) { // skip loop node itself variable: item, index - return - } - const isInLoop = isNodeInLoop(varSelector[0]) - if (isInLoop) // not pass loop inner variable - return - - const varSectorStr = varSelector.join('.') - if (!varObjs[varSectorStr]) { - varObjs[varSectorStr] = true - vars.push(varSelector) - } - let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector) - if (typeof passToServerKeys === 'string') - passToServerKeys = [passToServerKeys] - - passToServerKeys.forEach((key: string, index: number) => { - allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = { - inSingleRunPassedKey: key, - } - }) - }) - }) - - const res = toVarInputs(vars.map((item) => { - const varInfo = getNodeInfoById(canChooseVarNodes, item[0]) - return { - label: { - nodeType: varInfo?.data.type, - nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title - variable: isSystemVar(item) ? item.join('.') : item[item.length - 1], - }, - variable: `${item.join('.')}`, - value_selector: item, - } - })) - return { - usedOutVars: res, - allVarObject, - } - })() + const { usedOutVars, allVarObject } = useMemo(() => buildUsedOutVars({ + loopChildrenNodes, + currentNodeId: id, + canChooseVarNodes, + isNodeInLoop, + toVarInputs, + }), [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs]) const nodeInfo = useMemo(() => { const formattedNodeInfo = formatTracing(loopRunResult, t)[0] @@ -110,38 +72,9 @@ const useSingleRunFormParams = ({ setRunInputData(newPayload) }, [setRunInputData]) - const inputVarValues = (() => { - const vars: Record = {} - Object.keys(runInputData) - .forEach((key) => { - vars[key] = runInputData[key] - }) - return vars - })() + const inputVarValues = useMemo(() => createInputVarValues(runInputData), [runInputData]) - const getVarSelectorsFromCase = (caseItem: CaseItem): ValueSelector[] => { - const vars: ValueSelector[] = [] - if (caseItem.conditions && caseItem.conditions.length) { - caseItem.conditions.forEach((condition) => { - // eslint-disable-next-line ts/no-use-before-define - const conditionVars = getVarSelectorsFromCondition(condition) - vars.push(...conditionVars) - }) - } - return vars - } - - const getVarSelectorsFromCondition = (condition: Condition) => { - const vars: ValueSelector[] = [] - if (condition.variable_selector) - vars.push(condition.variable_selector) - - if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length) - vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition)) - return vars - } - - const forms = (() => { + const forms = useMemo(() => { const allInputs: ValueSelector[] = [] payload.break_conditions?.forEach((condition) => { const vars = getVarSelectorsFromCondition(condition) @@ -154,16 +87,7 @@ const useSingleRunFormParams = ({ }) const inputVarsFromValue: InputVar[] = [] const varInputs = [...varSelectorsToVarInputs(allInputs), ...inputVarsFromValue] - const existVarsKey: Record = {} - const uniqueVarInputs: InputVar[] = [] - varInputs.forEach((input) => { - if (!input) - return - if (!existVarsKey[input.variable]) { - existVarsKey[input.variable] = true - uniqueVarInputs.push(input) - } - }) + const uniqueVarInputs = dedupeInputVars(varInputs) return [ { inputs: [...usedOutVars, ...uniqueVarInputs], @@ -171,43 +95,14 @@ const useSingleRunFormParams = ({ onChange: setInputVarValues, }, ] - })() + }, [payload.break_conditions, payload.loop_variables, varSelectorsToVarInputs, usedOutVars, inputVarValues, setInputVarValues]) - const getVarFromCaseItem = (caseItem: CaseItem): ValueSelector[] => { - const vars: ValueSelector[] = [] - if (caseItem.conditions && caseItem.conditions.length) { - caseItem.conditions.forEach((condition) => { - // eslint-disable-next-line ts/no-use-before-define - const conditionVars = getVarFromCondition(condition) - vars.push(...conditionVars) - }) - } - return vars - } - - const getVarFromCondition = (condition: Condition): ValueSelector[] => { - const vars: ValueSelector[] = [] - if (condition.variable_selector) - vars.push(condition.variable_selector) - - if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length) - vars.push(...getVarFromCaseItem(condition.sub_variable_condition)) - return vars - } - - const getDependentVars = () => { - const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.')) - payload.break_conditions?.forEach((condition) => { - const conditionVars = getVarFromCondition(condition) - vars.push(...conditionVars) - }) - payload.loop_variables?.forEach((loopVariable) => { - if (loopVariable.value_type === ValueType.variable) - vars.push(loopVariable.value) - }) - const hasFilterLoopVars = vars.filter(item => item[0] !== id) - return hasFilterLoopVars - } + const getDependentVars = useCallback(() => getDependentVarsFromLoopPayload({ + nodeId: id, + usedOutVars, + breakConditions: payload.break_conditions, + loopVariables: payload.loop_variables, + }), [id, usedOutVars, payload.break_conditions, payload.loop_variables]) return { forms, diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts new file mode 100644 index 0000000000..6154131691 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.helpers.spec.ts @@ -0,0 +1,196 @@ +import type { WebhookTriggerNodeType } from '../types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { + syncVariables, + updateContentType, + updateMethod, + updateSimpleField, + updateSourceFields, + updateWebhookUrls, +} from '../use-config.helpers' +import { WEBHOOK_RAW_VARIABLE_NAME } from '../utils/raw-variable' + +const createInputs = (): WebhookTriggerNodeType => ({ + title: 'Webhook', + desc: '', + type: BlockEnum.TriggerWebhook, + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: false, + status_code: 200, + response_body: '', + variables: [ + { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + { variable: 'body_value', label: 'body', required: true, value_selector: [], value_type: VarType.string }, + ], +} as unknown as WebhookTriggerNodeType) + +describe('trigger webhook config helpers', () => { + it('syncs variables, updates existing ones and validates names', () => { + const notifyError = vi.fn() + const isVarUsedInNodes = vi.fn(([_, variable]) => variable === 'old_param') + const removeUsedVarInNodes = vi.fn() + const draft = { + ...createInputs(), + variables: [ + { variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number }, + { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + ], + } + + expect(syncVariables({ + draft, + id: 'node-1', + newData: [{ name: 'existing_header', type: VarType.string, required: true }], + sourceType: 'header', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(true) + expect(draft.variables).toContainEqual(expect.objectContaining({ + variable: 'existing_header', + label: 'header', + required: true, + })) + + expect(syncVariables({ + draft, + id: 'node-1', + newData: [{ name: '1invalid', type: VarType.string, required: true }], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(false) + expect(notifyError).toHaveBeenCalledWith('varKeyError.notStartWithNumber') + + expect(syncVariables({ + draft: createInputs(), + id: 'node-1', + newData: [ + { name: 'x-request-id', type: VarType.string, required: true }, + { name: 'x-request-id', type: VarType.string, required: false }, + ], + sourceType: 'header', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(false) + expect(notifyError).toHaveBeenCalledWith('variableConfig.varName') + + expect(syncVariables({ + draft: { + ...createInputs(), + variables: undefined, + } as unknown as WebhookTriggerNodeType, + id: 'node-1', + newData: [{ name: WEBHOOK_RAW_VARIABLE_NAME, type: VarType.string, required: true }], + sourceType: 'body', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(false) + expect(notifyError).toHaveBeenCalledWith('variableConfig.varName') + + expect(syncVariables({ + draft: createInputs(), + id: 'node-1', + newData: [{ name: 'existing_header', type: VarType.string, required: true }], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(false) + expect(notifyError).toHaveBeenCalledWith('existing_header') + + const removableDraft = { + ...createInputs(), + variables: [ + { variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number }, + ], + } + expect(syncVariables({ + draft: removableDraft, + id: 'node-1', + newData: [], + sourceType: 'param', + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + })).toBe(true) + expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'old_param']) + }) + + it('updates content, source fields and webhook urls', () => { + const removeUsedVarInNodes = vi.fn() + const nextContentType = updateContentType({ + inputs: createInputs(), + id: 'node-1', + contentType: 'text/plain', + isVarUsedInNodes: () => true, + removeUsedVarInNodes, + }) + expect(nextContentType.body).toEqual([]) + expect(nextContentType.variables.every(item => item.label !== 'body')).toBe(true) + expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'body_value']) + + expect(updateContentType({ + inputs: createInputs(), + id: 'node-1', + contentType: 'application/json', + isVarUsedInNodes: () => false, + removeUsedVarInNodes, + }).body).toEqual([]) + + expect(updateContentType({ + inputs: { + ...createInputs(), + variables: undefined, + } as unknown as WebhookTriggerNodeType, + id: 'node-1', + contentType: 'multipart/form-data', + isVarUsedInNodes: () => false, + removeUsedVarInNodes, + }).body).toEqual([]) + + expect(updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'param', + nextData: [{ name: 'page', type: VarType.number, required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).params).toEqual([{ name: 'page', type: VarType.number, required: true }]) + + expect(updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'body', + nextData: [{ name: 'payload', type: VarType.string, required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).body).toEqual([{ name: 'payload', type: VarType.string, required: true }]) + + expect(updateSourceFields({ + inputs: createInputs(), + id: 'node-1', + sourceType: 'header', + nextData: [{ name: 'x-request-id', required: true }], + notifyError: vi.fn(), + isVarUsedInNodes: () => false, + removeUsedVarInNodes: vi.fn(), + }).headers).toEqual([{ name: 'x-request-id', required: true }]) + + expect(updateMethod(createInputs(), 'GET').method).toBe('GET') + expect(updateSimpleField(createInputs(), 'status_code', 204).status_code).toBe(204) + expect(updateWebhookUrls(createInputs(), 'https://hook', 'https://debug')).toEqual(expect.objectContaining({ + webhook_url: 'https://hook', + webhook_debug_url: 'https://debug', + })) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..46d0490b65 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx @@ -0,0 +1,207 @@ +import type { WebhookTriggerNodeType } from '../types' +import { renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import Toast from '@/app/components/base/toast' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { fetchWebhookUrl } from '@/service/apps' +import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils' +import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn()) +const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn()) +const mockUseNodesReadOnly = vi.hoisted(() => vi.fn()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => options?.key || key, + }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + __esModule: true, + default: { + notify: vi.fn(), + }, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => mockUseNodesReadOnly(), + useWorkflow: () => ({ + isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), + removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('@/service/apps', () => ({ + fetchWebhookUrl: vi.fn(), +})) + +const mockedFetchWebhookUrl = vi.mocked(fetchWebhookUrl) +const mockedToastNotify = vi.mocked(Toast.notify) + +const createPayload = (overrides: Partial = {}): WebhookTriggerNodeType => ({ + title: 'Webhook', + desc: '', + type: BlockEnum.TriggerWebhook, + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: false, + status_code: 200, + response_body: '', + variables: [], + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(useAppStore, 'getState').mockReturnValue({ + appDetail: { id: 'app-1' }, + } as never) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false }) + mockIsVarUsedInNodes.mockReturnValue(false) + }) + + it('should update simple fields and reset body variables when content type changes', () => { + const payload = createPayload({ + content_type: 'application/json', + body: [{ name: 'payload', type: VarType.string, required: true }], + variables: [ + { variable: 'payload', label: 'body', required: true, value_selector: [], value_type: VarType.string }, + { variable: 'token', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + ], + }) + mockIsVarUsedInNodes.mockImplementation(([_, variable]) => variable === 'payload') + const { result } = renderHook(() => useConfig('webhook-node', payload)) + + result.current.handleMethodChange('GET') + result.current.handleContentTypeChange('text/plain') + result.current.handleAsyncModeChange(true) + result.current.handleStatusCodeChange(204) + result.current.handleResponseBodyChange('ok') + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + method: 'GET', + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + content_type: 'text/plain', + body: [], + variables: [ + expect.objectContaining({ + variable: 'token', + label: 'header', + }), + ], + })) + expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['webhook-node', 'payload']) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ async_mode: true })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ status_code: 204 })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ response_body: 'ok' })) + }) + + it('should sync params, headers and body variables and reject conflicting names', () => { + const payload = createPayload({ + variables: [ + { variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string }, + ], + }) + const { result } = renderHook(() => useConfig('webhook-node', payload)) + + result.current.handleParamsChange([{ name: 'page', type: VarType.number, required: true }]) + result.current.handleHeadersChange([{ name: 'x-request-id', required: false }]) + result.current.handleBodyChange([{ name: 'body_field', type: VarType.string, required: true }]) + result.current.handleParamsChange([{ name: 'existing_header', type: VarType.string, required: true }]) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + params: [{ name: 'page', type: VarType.number, required: true }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'page', + label: 'param', + value_type: VarType.number, + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + headers: [{ name: 'x-request-id', required: false }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'x_request_id', + label: 'header', + }), + ]), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + body: [{ name: 'body_field', type: VarType.string, required: true }], + variables: expect.arrayContaining([ + expect.objectContaining({ + variable: 'body_field', + label: 'body', + }), + ]), + })) + expect(mockedToastNotify).toHaveBeenCalledTimes(1) + }) + + it('should generate webhook urls once and fall back to empty url on request failure', async () => { + mockedFetchWebhookUrl.mockResolvedValueOnce({ + webhook_url: 'https://example.com/hook', + webhook_debug_url: 'https://example.com/debug', + } as never) + mockedFetchWebhookUrl.mockRejectedValueOnce(new Error('boom')) + + const { result, rerender } = renderHook(({ payload }) => useConfig('webhook-node', payload), { + initialProps: { + payload: createPayload(), + }, + }) + + await result.current.generateWebhookUrl() + expect(mockedFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-1', nodeId: 'webhook-node' }) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + webhook_url: 'https://example.com/hook', + webhook_debug_url: 'https://example.com/debug', + })) + + rerender({ + payload: createPayload(), + }) + await result.current.generateWebhookUrl() + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + webhook_url: '', + })) + + rerender({ + payload: createPayload({ webhook_url: 'https://already-exists' }), + }) + await result.current.generateWebhookUrl() + expect(mockedFetchWebhookUrl).toHaveBeenCalledTimes(2) + }) + + it('should expose readonly state, clamp status codes and skip url generation without app id', async () => { + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true }) + vi.spyOn(useAppStore, 'getState').mockReturnValue({ + appDetail: undefined, + } as never) + + const { result } = renderHook(() => useConfig('webhook-node', createPayload())) + + expect(result.current.readOnly).toBe(true) + expect(normalizeStatusCode(DEFAULT_STATUS_CODE - 10)).toBe(DEFAULT_STATUS_CODE) + expect(normalizeStatusCode(248)).toBe(248) + expect(normalizeStatusCode(MAX_STATUS_CODE + 10)).toBe(MAX_STATUS_CODE) + + await result.current.generateWebhookUrl() + + expect(mockedFetchWebhookUrl).not.toHaveBeenCalled() + expect(mockSetInputs).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx new file mode 100644 index 0000000000..6b5d59bd65 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx @@ -0,0 +1,197 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import GenericTable from '../generic-table' + +const columns = [ + { + key: 'name', + title: 'Name', + type: 'input' as const, + placeholder: 'Name', + width: 'w-[140px]', + }, + { + key: 'enabled', + title: 'Enabled', + type: 'switch' as const, + width: 'w-[80px]', + }, +] + +const advancedColumns = [ + { + key: 'method', + title: 'Method', + type: 'select' as const, + placeholder: 'Choose method', + options: [{ name: 'POST', value: 'post' }], + width: 'w-[120px]', + }, + { + key: 'preview', + title: 'Preview', + type: 'custom' as const, + width: 'w-[120px]', + render: (_value: unknown, row: { method?: string }, index: number, onChange: (value: unknown) => void) => ( + + ), + }, + { + key: 'unsupported', + title: 'Unsupported', + type: 'unsupported' as never, + width: 'w-[80px]', + }, +] + +describe('GenericTable', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an empty editable row and append a configured row when typing into the virtual row', async () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'my key' } }) + + expect(onChange).toHaveBeenLastCalledWith([{ name: 'my_key', enabled: false }]) + }) + + it('should skip intermediate empty rows and blur the current input when enter is pressed', () => { + render( + , + ) + + const inputs = screen.getAllByRole('textbox') + expect(inputs).toHaveLength(3) + expect(screen.getAllByRole('button', { name: 'Delete row' })).toHaveLength(2) + + const blurSpy = vi.spyOn(inputs[0], 'blur') + fireEvent.keyDown(inputs[0], { key: 'Enter' }) + expect(blurSpy).toHaveBeenCalledTimes(1) + }) + + it('should update existing rows, show delete action, and remove rows by primary key', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('Name')).toBeInTheDocument() + + await user.click(screen.getAllByRole('checkbox')[0]) + expect(onChange).toHaveBeenCalledWith([{ name: 'alpha', enabled: true }]) + + await user.click(screen.getByRole('button', { name: 'Delete row' })) + expect(onChange).toHaveBeenLastCalledWith([]) + }) + + it('should update select and custom cells for existing rows', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + const ControlledTable = () => { + const [data, setData] = useState([{ method: '', preview: '' }]) + + return ( + { + onChange(nextData) + setData(nextData as { method: string, preview: string }[]) + }} + /> + ) + } + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Choose method' })) + await user.click(await screen.findByText('POST')) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }]) + }) + + onChange.mockClear() + await user.click(screen.getAllByRole('button', { name: 'custom-render' })[0]) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '0:post' }]) + }) + }) + + it('should ignore custom-cell updates when readonly rows are rendered', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'custom-render' })) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should show readonly placeholder without rendering editable rows', () => { + render( + , + ) + + expect(screen.getByText('No data')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx index 0d31428bd2..8aad5b2b5c 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -57,6 +57,126 @@ type DisplayRow = { isVirtual: boolean // whether this row is the extra empty row for adding new items } +const isEmptyRow = (row: GenericTableRow) => { + return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false) +} + +const getDisplayRows = ( + data: GenericTableRow[], + emptyRowData: GenericTableRow, + readonly: boolean, +): DisplayRow[] => { + if (readonly) + return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false })) + + if (!data.length) + return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }] + + const rows = data.reduce((acc, row, index) => { + if (isEmptyRow(row) && index < data.length - 1) + return acc + + acc.push({ row, dataIndex: index, isVirtual: false }) + return acc + }, []) + + const lastRow = data.at(-1) + if (lastRow && !isEmptyRow(lastRow)) + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + + return rows +} + +const getPrimaryKey = (columns: ColumnConfig[]) => { + return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' +} + +const renderInputCell = ( + column: ColumnConfig, + value: unknown, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( + { + if (column.key === 'key' || column.key === 'name') + replaceSpaceWithUnderscoreInVarNameInput(e.target) + handleChange(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } + }} + placeholder={column.placeholder} + disabled={readonly} + wrapperClassName="w-full min-w-0" + className={cn( + 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', + 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', + 'text-text-secondary system-sm-regular placeholder:text-text-quaternary', + )} + /> + ) +} + +const renderSelectCell = ( + column: ColumnConfig, + value: unknown, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( + handleChange(item.value)} + disabled={readonly} + placeholder={column.placeholder} + hideChecked={false} + notClearable={true} + wrapperClassName="h-6 w-full min-w-0" + className={cn( + 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', + 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', + )} + optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" + /> + ) +} + +const renderSwitchCell = ( + column: ColumnConfig, + value: unknown, + dataIndex: number | null, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( +
+ handleChange(!value)} + disabled={readonly} + /> +
+ ) +} + +const renderCustomCell = ( + column: ColumnConfig, + value: unknown, + row: GenericTableRow, + dataIndex: number | null, + handleChange: (value: unknown) => void, +) => { + return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null +} + const GenericTable: FC = ({ title, columns, @@ -68,42 +188,8 @@ const GenericTable: FC = ({ className, showHeader = false, }) => { - // Build the rows to display while keeping a stable mapping to original data const displayRows = useMemo(() => { - // Helper to check empty - const isEmptyRow = (r: GenericTableRow) => - Object.values(r).every(v => v === '' || v === null || v === undefined || v === false) - - if (readonly) - return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false })) - - const hasData = data.length > 0 - const rows: DisplayRow[] = [] - - if (!hasData) { - // Initialize with exactly one empty row when there is no data - rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) - return rows - } - - // Add configured rows, hide intermediate empty ones, keep mapping - data.forEach((r, i) => { - const isEmpty = isEmptyRow(r) - // Skip empty rows except the very last configured row - if (isEmpty && i < data.length - 1) - return - rows.push({ row: r, dataIndex: i, isVirtual: false }) - }) - - // If the last configured row has content, append a trailing empty row - const lastRow = data.at(-1) - if (!lastRow) - return rows - const lastHasContent = !isEmptyRow(lastRow) - if (lastHasContent) - rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) - - return rows + return getDisplayRows(data, emptyRowData, readonly) }, [data, emptyRowData, readonly]) const removeRow = useCallback((dataIndex: number) => { @@ -134,9 +220,7 @@ const GenericTable: FC = ({ }, [data, emptyRowData, onChange, readonly]) // Determine the primary identifier column just once - const primaryKey = useMemo(() => ( - columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' - ), [columns]) + const primaryKey = useMemo(() => getPrimaryKey(columns), [columns]) const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => { const value = row[column.key] @@ -144,67 +228,16 @@ const GenericTable: FC = ({ switch (column.type) { case 'input': - return ( - { - // Format variable names (replace spaces with underscores) - if (column.key === 'key' || column.key === 'name') - replaceSpaceWithUnderscoreInVarNameInput(e.target) - handleChange(e.target.value) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.currentTarget.blur() - } - }} - placeholder={column.placeholder} - disabled={readonly} - wrapperClassName="w-full min-w-0" - className={cn( - // Ghost/inline style: looks like plain text until focus/hover - 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', - 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', - 'text-text-secondary system-sm-regular placeholder:text-text-quaternary', - )} - /> - ) + return renderInputCell(column, value, readonly, handleChange) case 'select': - return ( - handleChange(item.value)} - disabled={readonly} - placeholder={column.placeholder} - hideChecked={false} - notClearable={true} - // wrapper provides compact height, trigger is transparent like text - wrapperClassName="h-6 w-full min-w-0" - className={cn( - 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', - 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', - )} - optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" - /> - ) + return renderSelectCell(column, value, readonly, handleChange) case 'switch': - return ( -
- handleChange(!value)} - disabled={readonly} - /> -
- ) + return renderSwitchCell(column, value, dataIndex, readonly, handleChange) case 'custom': - return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null + return renderCustomCell(column, value, row, dataIndex, handleChange) default: return null @@ -270,6 +303,7 @@ const GenericTable: FC = ({ className="p-1" aria-label="Delete row" > + {/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts new file mode 100644 index 0000000000..20ea7fdbf8 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.helpers.ts @@ -0,0 +1,220 @@ +import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' +import type { Variable } from '@/app/components/workflow/types' +import { produce } from 'immer' +import { VarType } from '@/app/components/workflow/types' +import { checkKeys, hasDuplicateStr } from '@/utils/var' +import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable' + +export type VariableSyncSource = 'param' | 'header' | 'body' + +type SanitizedEntry = { + item: WebhookParameter | WebhookHeader + sanitizedName: string +} + +type NotifyError = (key: string) => void + +const sanitizeEntryName = (item: WebhookParameter | WebhookHeader, sourceType: VariableSyncSource) => { + return sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name +} + +const getSanitizedEntries = ( + newData: (WebhookParameter | WebhookHeader)[], + sourceType: VariableSyncSource, +): SanitizedEntry[] => { + return newData.map(item => ({ + item, + sanitizedName: sanitizeEntryName(item, sourceType), + })) +} + +const createVariable = ( + item: WebhookParameter | WebhookHeader, + sourceType: VariableSyncSource, + sanitizedName: string, +): Variable => { + const inputVarType: VarType = 'type' in item ? item.type : VarType.string + + return { + value_type: inputVarType, + label: sourceType, + variable: sanitizedName, + value_selector: [], + required: item.required, + } +} + +export const syncVariables = ({ + draft, + id, + newData, + sourceType, + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, +}: { + draft: WebhookTriggerNodeType + id: string + newData: (WebhookParameter | WebhookHeader)[] + sourceType: VariableSyncSource + notifyError: NotifyError + isVarUsedInNodes: (selector: [string, string]) => boolean + removeUsedVarInNodes: (selector: [string, string]) => void +}) => { + if (!draft.variables) + draft.variables = [] + + const sanitizedEntries = getSanitizedEntries(newData, sourceType) + if (sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)) { + notifyError('variableConfig.varName') + return false + } + + const existingOtherVarNames = new Set( + draft.variables + .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) + .map(v => v.variable), + ) + + const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) + if (crossScopeConflict) { + notifyError(crossScopeConflict.sanitizedName) + return false + } + + if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { + notifyError('variableConfig.varName') + return false + } + + for (const { sanitizedName } of sanitizedEntries) { + const { isValid, errorMessageKey } = checkKeys([sanitizedName], false) + if (!isValid) { + notifyError(`varKeyError.${errorMessageKey}`) + return false + } + } + + const nextNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) + draft.variables + .filter(v => v.label === sourceType && !nextNames.has(v.variable)) + .forEach((variable) => { + if (isVarUsedInNodes([id, variable.variable])) + removeUsedVarInNodes([id, variable.variable]) + }) + + draft.variables = draft.variables.filter((variable) => { + if (variable.label !== sourceType) + return true + return nextNames.has(variable.variable) + }) + + sanitizedEntries.forEach(({ item, sanitizedName }) => { + const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) + const variable = createVariable(item, sourceType, sanitizedName) + if (existingVarIndex >= 0) + draft.variables[existingVarIndex] = variable + else + draft.variables.push(variable) + }) + + return true +} + +export const updateMethod = (inputs: WebhookTriggerNodeType, method: HttpMethod) => produce(inputs, (draft) => { + draft.method = method +}) + +export const updateSimpleField = < + K extends 'async_mode' | 'status_code' | 'response_body', +>( + inputs: WebhookTriggerNodeType, + key: K, + value: WebhookTriggerNodeType[K], +) => produce(inputs, (draft) => { + draft[key] = value +}) + +export const updateContentType = ({ + inputs, + id, + contentType, + isVarUsedInNodes, + removeUsedVarInNodes, +}: { + inputs: WebhookTriggerNodeType + id: string + contentType: string + isVarUsedInNodes: (selector: [string, string]) => boolean + removeUsedVarInNodes: (selector: [string, string]) => void +}) => produce(inputs, (draft) => { + const previousContentType = draft.content_type + draft.content_type = contentType + + if (previousContentType === contentType) + return + + draft.body = [] + if (!draft.variables) + return + + draft.variables + .filter(v => v.label === 'body') + .forEach((variable) => { + if (isVarUsedInNodes([id, variable.variable])) + removeUsedVarInNodes([id, variable.variable]) + }) + + draft.variables = draft.variables.filter(v => v.label !== 'body') +}) + +type SourceField = 'params' | 'headers' | 'body' + +const getSourceField = (sourceType: VariableSyncSource): SourceField => { + switch (sourceType) { + case 'param': + return 'params' + case 'header': + return 'headers' + default: + return 'body' + } +} + +export const updateSourceFields = ({ + inputs, + id, + sourceType, + nextData, + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, +}: { + inputs: WebhookTriggerNodeType + id: string + sourceType: VariableSyncSource + nextData: WebhookParameter[] | WebhookHeader[] + notifyError: NotifyError + isVarUsedInNodes: (selector: [string, string]) => boolean + removeUsedVarInNodes: (selector: [string, string]) => void +}) => produce(inputs, (draft) => { + draft[getSourceField(sourceType)] = nextData as never + syncVariables({ + draft, + id, + newData: nextData, + sourceType, + notifyError, + isVarUsedInNodes, + removeUsedVarInNodes, + }) +}) + +export const updateWebhookUrls = ( + inputs: WebhookTriggerNodeType, + webhookUrl: string, + webhookDebugUrl?: string, +) => produce(inputs, (draft) => { + draft.webhook_url = webhookUrl + draft.webhook_debug_url = webhookDebugUrl +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index ca3f63c317..15ebff7736 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -1,17 +1,18 @@ import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' -import type { Variable } from '@/app/components/workflow/types' -import { produce } from 'immer' import { useCallback } from 'react' - import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Toast from '@/app/components/base/toast' import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { VarType } from '@/app/components/workflow/types' import { fetchWebhookUrl } from '@/service/apps' -import { checkKeys, hasDuplicateStr } from '@/utils/var' -import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable' +import { + updateContentType, + updateMethod, + updateSimpleField, + updateSourceFields, + updateWebhookUrls, +} from './use-config.helpers' export const DEFAULT_STATUS_CODE = 200 export const MAX_STATUS_CODE = 399 @@ -24,182 +25,80 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => { const appId = useAppStore.getState().appDetail?.id const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() + const notifyVarError = useCallback((key: string) => { + const fieldLabel = key === 'variableConfig.varName' + ? t('variableConfig.varName', { ns: 'appDebug' }) + : key + const message = key.startsWith('varKeyError.') + ? t(key as never, { ns: 'appDebug', key: fieldLabel }) + : t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: fieldLabel }) + + Toast.notify({ + type: 'error', + message, + }) + }, [t]) + const handleMethodChange = useCallback((method: HttpMethod) => { - setInputs(produce(inputs, (draft) => { - draft.method = method - })) + setInputs(updateMethod(inputs, method)) }, [inputs, setInputs]) const handleContentTypeChange = useCallback((contentType: string) => { - setInputs(produce(inputs, (draft) => { - const previousContentType = draft.content_type - draft.content_type = contentType - - // If the content type changes, reset body parameters and their variables, as the variable types might differ. - // However, we could consider retaining variables that are compatible with the new content type later. - if (previousContentType !== contentType) { - draft.body = [] - if (draft.variables) { - const bodyVariables = draft.variables.filter(v => v.label === 'body') - bodyVariables.forEach((v) => { - if (isVarUsedInNodes([id, v.variable])) - removeUsedVarInNodes([id, v.variable]) - }) - - draft.variables = draft.variables.filter(v => v.label !== 'body') - } - } + setInputs(updateContentType({ + inputs, + id, + contentType, + isVarUsedInNodes, + removeUsedVarInNodes, })) }, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes]) - const syncVariablesInDraft = useCallback(( - draft: WebhookTriggerNodeType, - newData: (WebhookParameter | WebhookHeader)[], - sourceType: 'param' | 'header' | 'body', - ) => { - if (!draft.variables) - draft.variables = [] - - const sanitizedEntries = newData.map(item => ({ - item, - sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name, - })) - - const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME) - if (hasReservedConflict) { - Toast.notify({ - type: 'error', - message: t('varKeyError.keyAlreadyExists', { - ns: 'appDebug', - key: t('variableConfig.varName', { ns: 'appDebug' }), - }), - }) - return false - } - const existingOtherVarNames = new Set( - draft.variables - .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) - .map(v => v.variable), - ) - - const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) - if (crossScopeConflict) { - Toast.notify({ - type: 'error', - message: t('varKeyError.keyAlreadyExists', { - ns: 'appDebug', - key: crossScopeConflict.sanitizedName, - }), - }) - return false - } - - if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { - Toast.notify({ - type: 'error', - message: t('varKeyError.keyAlreadyExists', { - ns: 'appDebug', - key: t('variableConfig.varName', { ns: 'appDebug' }), - }), - }) - return false - } - - for (const { sanitizedName } of sanitizedEntries) { - const { isValid, errorMessageKey } = checkKeys([sanitizedName], false) - if (!isValid) { - Toast.notify({ - type: 'error', - message: t(`varKeyError.${errorMessageKey}`, { - ns: 'appDebug', - key: t('variableConfig.varName', { ns: 'appDebug' }), - }), - }) - return false - } - } - - // Create set of new variable names for this source - const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) - - // Find variables from current source that will be deleted and clean up references - draft.variables - .filter(v => v.label === sourceType && !newVarNames.has(v.variable)) - .forEach((v) => { - // Clean up references if variable is used in other nodes - if (isVarUsedInNodes([id, v.variable])) - removeUsedVarInNodes([id, v.variable]) - }) - - // Remove variables that no longer exist in newData for this specific source type - draft.variables = draft.variables.filter((v) => { - // Keep variables from other sources - if (v.label !== sourceType) - return true - return newVarNames.has(v.variable) - }) - - // Add or update variables - sanitizedEntries.forEach(({ item, sanitizedName }) => { - const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) - - const inputVarType = 'type' in item - ? item.type - : VarType.string // Default to string for headers - - const newVar: Variable = { - value_type: inputVarType, - label: sourceType, // Use sourceType as label to identify source - variable: sanitizedName, - value_selector: [], - required: item.required, - } - - if (existingVarIndex >= 0) - draft.variables[existingVarIndex] = newVar - else - draft.variables.push(newVar) - }) - return true - }, [t, id, isVarUsedInNodes, removeUsedVarInNodes]) - const handleParamsChange = useCallback((params: WebhookParameter[]) => { - setInputs(produce(inputs, (draft) => { - draft.params = params - syncVariablesInDraft(draft, params, 'param') + setInputs(updateSourceFields({ + inputs, + id, + sourceType: 'param', + nextData: params, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, })) - }, [inputs, setInputs, syncVariablesInDraft]) + }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) const handleHeadersChange = useCallback((headers: WebhookHeader[]) => { - setInputs(produce(inputs, (draft) => { - draft.headers = headers - syncVariablesInDraft(draft, headers, 'header') + setInputs(updateSourceFields({ + inputs, + id, + sourceType: 'header', + nextData: headers, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, })) - }, [inputs, setInputs, syncVariablesInDraft]) + }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) const handleBodyChange = useCallback((body: WebhookParameter[]) => { - setInputs(produce(inputs, (draft) => { - draft.body = body - syncVariablesInDraft(draft, body, 'body') + setInputs(updateSourceFields({ + inputs, + id, + sourceType: 'body', + nextData: body, + notifyError: notifyVarError, + isVarUsedInNodes, + removeUsedVarInNodes, })) - }, [inputs, setInputs, syncVariablesInDraft]) + }, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs]) const handleAsyncModeChange = useCallback((asyncMode: boolean) => { - setInputs(produce(inputs, (draft) => { - draft.async_mode = asyncMode - })) + setInputs(updateSimpleField(inputs, 'async_mode', asyncMode)) }, [inputs, setInputs]) const handleStatusCodeChange = useCallback((statusCode: number) => { - setInputs(produce(inputs, (draft) => { - draft.status_code = statusCode - })) + setInputs(updateSimpleField(inputs, 'status_code', statusCode)) }, [inputs, setInputs]) const handleResponseBodyChange = useCallback((responseBody: string) => { - setInputs(produce(inputs, (draft) => { - draft.response_body = responseBody - })) + setInputs(updateSimpleField(inputs, 'response_body', responseBody)) }, [inputs, setInputs]) const generateWebhookUrl = useCallback(async () => { @@ -211,23 +110,12 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => { return try { - // Call backend to generate or fetch webhook url for this node const response = await fetchWebhookUrl({ appId, nodeId: id }) - - const newInputs = produce(inputs, (draft) => { - draft.webhook_url = response.webhook_url - draft.webhook_debug_url = response.webhook_debug_url - }) - setInputs(newInputs) + setInputs(updateWebhookUrls(inputs, response.webhook_url, response.webhook_debug_url)) } catch (error: unknown) { - // Fallback to mock URL when API is not ready or request fails - // Keep the UI unblocked and allow users to proceed in local/dev environments. console.error('Failed to generate webhook URL:', error) - const newInputs = produce(inputs, (draft) => { - draft.webhook_url = '' - }) - setInputs(newInputs) + setInputs(updateWebhookUrls(inputs, '')) } }, [appId, id, inputs, setInputs]) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx new file mode 100644 index 0000000000..1137f20a0c --- /dev/null +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx @@ -0,0 +1,255 @@ +import type { VariableAssignerNodeType } from '../types' +import { act, renderHook } from '@testing-library/react' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { + createNodeCrudModuleMock, + createUuidModuleMock, +} from '../../__tests__/use-config-test-utils' +import useConfig from '../use-config' + +const mockSetInputs = vi.hoisted(() => vi.fn()) +const mockDeleteNodeInspectorVars = vi.hoisted(() => vi.fn()) +const mockRenameInspectVarName = vi.hoisted(() => vi.fn()) +const mockHandleOutVarRenameChange = vi.hoisted(() => vi.fn()) +const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn()) +const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn()) +const mockGetAvailableVars = vi.hoisted(() => vi.fn()) +const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-group-id')) + +vi.mock('uuid', () => ({ + ...createUuidModuleMock(mockUuid), +})) + +vi.mock('ahooks', () => ({ + useBoolean: (initialValue: boolean) => { + let current = initialValue + return [ + current, + { + setTrue: () => { + current = true + }, + setFalse: () => { + current = false + }, + }, + ] as const + }, + useDebounceFn: (fn: (...args: unknown[]) => void) => ({ + run: fn, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useWorkflow: () => ({ + handleOutVarRenameChange: (...args: unknown[]) => mockHandleOutVarRenameChange(...args), + isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), + removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ + ...createNodeCrudModuleMock(mockSetInputs), +})) + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + __esModule: true, + default: () => ({ + deleteNodeInspectorVars: (...args: unknown[]) => mockDeleteNodeInspectorVars(...args), + renameInspectVarName: (...args: unknown[]) => mockRenameInspectVarName(...args), + }), +})) + +vi.mock('../hooks', () => ({ + useGetAvailableVars: () => mockGetAvailableVars, +})) + +const createPayload = (overrides: Partial = {}): VariableAssignerNodeType => ({ + title: 'Variable Assigner', + desc: '', + type: BlockEnum.VariableAssigner, + output_type: VarType.string, + variables: [['source-node', 'initialVar']], + advanced_settings: { + group_enabled: true, + groups: [ + { + groupId: 'group-1', + group_name: 'Group1', + output_type: VarType.string, + variables: [['source-node', 'initialVar']], + }, + { + groupId: 'group-2', + group_name: 'Group2', + output_type: VarType.number, + variables: [], + }, + ], + }, + ...overrides, +}) + +describe('useConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetAvailableVars.mockReturnValue([]) + mockIsVarUsedInNodes.mockReturnValue(false) + }) + + it('should expose read-only state, group mode and typed variable filters', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + expect(result.current.readOnly).toBe(false) + expect(result.current.isEnableGroup).toBe(true) + expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true) + expect(result.current.filterVar(VarType.number)({ type: VarType.string } as never)).toBe(false) + expect(result.current.getAvailableVars).toBe(mockGetAvailableVars) + }) + + it('should update root and grouped variable payloads', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + result.current.handleListOrTypeChange({ + output_type: VarType.number, + variables: [['source-node', 'changed']], + }) + result.current.handleListOrTypeChangeInGroup('group-1')({ + output_type: VarType.boolean, + variables: [['source-node', 'groupVar']], + }) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + output_type: VarType.number, + variables: [['source-node', 'changed']], + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [ + expect.objectContaining({ + groupId: 'group-1', + output_type: VarType.boolean, + variables: [['source-node', 'groupVar']], + }), + expect.anything(), + ], + }), + })) + }) + + it('should add and remove groups and toggle group mode', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + result.current.handleAddGroup() + result.current.handleGroupRemoved('group-2')() + result.current.handleGroupEnabledChange(false) + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: expect.arrayContaining([ + expect.objectContaining({ + groupId: 'generated-group-id', + group_name: 'Group3', + }), + ]), + }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [ + expect.objectContaining({ groupId: 'group-1' }), + ], + }), + })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + group_enabled: false, + }), + output_type: VarType.string, + variables: [['source-node', 'initialVar']], + })) + expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node') + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'assigner-node', + ['assigner-node', 'Group1', 'output'], + ['assigner-node', 'output'], + ) + }) + + it('should rename groups and remove used vars after confirmation', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + result.current.handleVarGroupNameChange('group-1')('Renamed') + result.current.onRemoveVarConfirm() + + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [ + expect.objectContaining({ + groupId: 'group-1', + group_name: 'Renamed', + }), + expect.anything(), + ], + }), + })) + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'assigner-node', + ['assigner-node', 'Group1', 'output'], + ['assigner-node', 'Renamed', 'output'], + ) + expect(mockRenameInspectVarName).toHaveBeenCalledWith('assigner-node', 'Group1', 'Renamed') + }) + + it('should confirm removing a used group before deleting it', () => { + mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2') + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + act(() => { + result.current.handleGroupRemoved('group-2')() + }) + act(() => { + result.current.onRemoveVarConfirm() + }) + + expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output']) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + groups: [expect.objectContaining({ groupId: 'group-1' })], + }), + })) + }) + + it('should enable empty groups and confirm disabling when downstream vars are used', () => { + const { result: enableResult } = renderHook(() => useConfig('assigner-node', createPayload({ + advanced_settings: { + group_enabled: false, + groups: [], + }, + }))) + + enableResult.current.handleGroupEnabledChange(true) + + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'assigner-node', + ['assigner-node', 'output'], + ['assigner-node', 'Group1', 'output'], + ) + + mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2') + const { result } = renderHook(() => useConfig('assigner-node', createPayload())) + + act(() => { + result.current.handleGroupEnabledChange(false) + }) + act(() => { + result.current.onRemoveVarConfirm() + }) + + expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output']) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ group_enabled: false }), + })) + }) +}) diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts new file mode 100644 index 0000000000..31300557b2 --- /dev/null +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts @@ -0,0 +1,99 @@ +import type { Var } from '../../types' +import type { VarGroupItem, VariableAssignerNodeType } from './types' +import { produce } from 'immer' +import { v4 as uuid4 } from 'uuid' +import { VarType } from '../../types' + +export const filterVarByType = (varType: VarType) => { + return (variable: Var) => { + if (varType === VarType.any || variable.type === VarType.any) + return true + + return variable.type === varType + } +} + +export const updateRootVarGroupItem = ( + inputs: VariableAssignerNodeType, + payload: VarGroupItem, +) => ({ + ...inputs, + ...payload, +}) + +export const updateNestedVarGroupItem = ( + inputs: VariableAssignerNodeType, + groupId: string, + payload: VarGroupItem, +) => produce(inputs, (draft) => { + const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) + draft.advanced_settings.groups[index] = { + ...draft.advanced_settings.groups[index], + ...payload, + } +}) + +export const removeGroupByIndex = ( + inputs: VariableAssignerNodeType, + index: number, +) => produce(inputs, (draft) => { + draft.advanced_settings.groups.splice(index, 1) +}) + +export const toggleGroupEnabled = ({ + inputs, + enabled, +}: { + inputs: VariableAssignerNodeType + enabled: boolean +}) => produce(inputs, (draft) => { + if (!draft.advanced_settings) + draft.advanced_settings = { group_enabled: false, groups: [] } + + if (enabled) { + if (draft.advanced_settings.groups.length === 0) { + draft.advanced_settings.groups = [{ + output_type: draft.output_type, + variables: draft.variables, + group_name: 'Group1', + groupId: uuid4(), + }] + } + } + else if (draft.advanced_settings.groups.length > 0) { + draft.output_type = draft.advanced_settings.groups[0].output_type + draft.variables = draft.advanced_settings.groups[0].variables + } + + draft.advanced_settings.group_enabled = enabled +}) + +export const addGroup = (inputs: VariableAssignerNodeType) => { + let maxInGroupName = 1 + inputs.advanced_settings.groups.forEach((item) => { + const match = /(\d+)$/.exec(item.group_name) + if (match) { + const num = Number.parseInt(match[1], 10) + if (num > maxInGroupName) + maxInGroupName = num + } + }) + + return produce(inputs, (draft) => { + draft.advanced_settings.groups.push({ + output_type: VarType.any, + variables: [], + group_name: `Group${maxInGroupName + 1}`, + groupId: uuid4(), + }) + }) +} + +export const renameGroup = ( + inputs: VariableAssignerNodeType, + groupId: string, + name: string, +) => produce(inputs, (draft) => { + const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) + draft.advanced_settings.groups[index].group_name = name +}) diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.ts index 286eed523d..6d4b27e50b 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.ts @@ -1,9 +1,7 @@ -import type { ValueSelector, Var } from '../../types' +import type { ValueSelector } from '../../types' import type { VarGroupItem, VariableAssignerNodeType } from './types' import { useBoolean, useDebounceFn } from 'ahooks' -import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' -import { v4 as uuid4 } from 'uuid' import { useNodesReadOnly, useWorkflow, @@ -11,8 +9,16 @@ import { import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' -import { VarType } from '../../types' import { useGetAvailableVars } from './hooks' +import { + addGroup, + filterVarByType, + removeGroupByIndex, + renameGroup, + toggleGroupEnabled, + updateNestedVarGroupItem, + updateRootVarGroupItem, +} from './use-config.helpers' const useConfig = (id: string, payload: VariableAssignerNodeType) => { const { @@ -27,35 +33,16 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { // Not Enable Group const handleListOrTypeChange = useCallback((payload: VarGroupItem) => { - setInputs({ - ...inputs, - ...payload, - }) + setInputs(updateRootVarGroupItem(inputs, payload)) }, [inputs, setInputs]) const handleListOrTypeChangeInGroup = useCallback((groupId: string) => { return (payload: VarGroupItem) => { - const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId) - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.groups[index] = { - ...draft.advanced_settings.groups[index], - ...payload, - } - }) - setInputs(newInputs) + setInputs(updateNestedVarGroupItem(inputs, groupId, payload)) } }, [inputs, setInputs]) const getAvailableVars = useGetAvailableVars() - const filterVar = (varType: VarType) => { - return (v: Var) => { - if (varType === VarType.any) - return true - if (v.type === VarType.any) - return true - return v.type === varType - } - } const [isShowRemoveVarConfirm, { setTrue: showRemoveVarConfirm, @@ -75,84 +62,48 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { setRemovedGroupIndex(index) return } - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.groups.splice(index, 1) - }) - setInputs(newInputs) + setInputs(removeGroupByIndex(inputs, index)) } }, [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm]) const handleGroupEnabledChange = useCallback((enabled: boolean) => { - const newInputs = produce(inputs, (draft) => { - if (!draft.advanced_settings) - draft.advanced_settings = { group_enabled: false, groups: [] } - if (enabled) { - if (draft.advanced_settings.groups.length === 0) { - const DEFAULT_GROUP_NAME = 'Group1' - draft.advanced_settings.groups = [{ - output_type: draft.output_type, - variables: draft.variables, - group_name: DEFAULT_GROUP_NAME, - groupId: uuid4(), - }] + if (enabled && inputs.advanced_settings.groups.length === 0) { + handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output']) + } - handleOutVarRenameChange(id, [id, 'output'], [id, DEFAULT_GROUP_NAME, 'output']) + if (!enabled && inputs.advanced_settings.groups.length > 0) { + if (inputs.advanced_settings.groups.length > 1) { + const useVars = inputs.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output'])) + if (useVars.length > 0) { + showRemoveVarConfirm() + setRemovedVars(useVars.map(item => [id, item.group_name, 'output'])) + setRemoveType('enableChanged') + return } } - else { - if (draft.advanced_settings.groups.length > 0) { - if (draft.advanced_settings.groups.length > 1) { - const useVars = draft.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output'])) - if (useVars.length > 0) { - showRemoveVarConfirm() - setRemovedVars(useVars.map(item => [id, item.group_name, 'output'])) - setRemoveType('enableChanged') - return - } - } - draft.output_type = draft.advanced_settings.groups[0].output_type - draft.variables = draft.advanced_settings.groups[0].variables - handleOutVarRenameChange(id, [id, draft.advanced_settings.groups[0].group_name, 'output'], [id, 'output']) - } - } - draft.advanced_settings.group_enabled = enabled - }) - setInputs(newInputs) + + handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[0].group_name, 'output'], [id, 'output']) + } + + setInputs(toggleGroupEnabled({ inputs, enabled })) deleteNodeInspectorVars(id) }, [deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm]) const handleAddGroup = useCallback(() => { - let maxInGroupName = 1 - inputs.advanced_settings.groups.forEach((item) => { - const match = /(\d+)$/.exec(item.group_name) - if (match) { - const num = Number.parseInt(match[1], 10) - if (num > maxInGroupName) - maxInGroupName = num - } - }) - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.groups.push({ - output_type: VarType.any, - variables: [], - group_name: `Group${maxInGroupName + 1}`, - groupId: uuid4(), - }) - }) - setInputs(newInputs) + setInputs(addGroup(inputs)) deleteNodeInspectorVars(id) }, [deleteNodeInspectorVars, id, inputs, setInputs]) // record the first old name value - const oldNameRecord = useRef>({}) + const oldNameRef = useRef>({}) const { run: renameInspectNameWithDebounce, } = useDebounceFn( (id: string, newName: string) => { - const oldName = oldNameRecord.current[id] + const oldName = oldNameRef.current[id] renameInspectVarName(id, oldName, newName) - delete oldNameRecord.current[id] + delete oldNameRef.current[id] }, { wait: 500 }, ) @@ -160,13 +111,10 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { const handleVarGroupNameChange = useCallback((groupId: string) => { return (name: string) => { const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId) - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.groups[index].group_name = name - }) handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[index].group_name, 'output'], [id, name, 'output']) - setInputs(newInputs) - if (!(id in oldNameRecord.current)) - oldNameRecord.current[id] = inputs.advanced_settings.groups[index].group_name + setInputs(renameGroup(inputs, groupId, name)) + if (!(id in oldNameRef.current)) + oldNameRef.current[id] = inputs.advanced_settings.groups[index].group_name renameInspectNameWithDebounce(id, name) } }, [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs]) @@ -177,19 +125,11 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { }) hideRemoveVarConfirm() if (removeType === 'group') { - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.groups.splice(removedGroupIndex, 1) - }) - setInputs(newInputs) + setInputs(removeGroupByIndex(inputs, removedGroupIndex)) } else { // removeType === 'enableChanged' to enabled - const newInputs = produce(inputs, (draft) => { - draft.advanced_settings.group_enabled = false - draft.output_type = draft.advanced_settings.groups[0].output_type - draft.variables = draft.advanced_settings.groups[0].variables - }) - setInputs(newInputs) + setInputs(toggleGroupEnabled({ inputs, enabled: false })) } }, [removedVars, hideRemoveVarConfirm, removeType, removeUsedVarInNodes, inputs, setInputs, removedGroupIndex]) @@ -207,7 +147,7 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { hideRemoveVarConfirm, onRemoveVarConfirm, getAvailableVars, - filterVar, + filterVar: filterVarByType, } } diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..af9aaac915 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react' +import { useCommand, useFontSize } from '../hooks' + +type MockSelectionParent = { isLink: boolean } | null + +const { + mockDispatchCommand, + mockEditorUpdate, + mockRegisterUpdateListener, + mockRegisterCommand, + mockRead, + mockSetLinkAnchorElement, + mockSelectionNode, + mockSelection, + mockPatchStyleText, + mockSetSelection, + mockSelectionFontSize, + mockIsRangeSelection, + mockSelectedIsBullet, + mockSetBlocksType, +} = vi.hoisted(() => ({ + mockDispatchCommand: vi.fn(), + mockEditorUpdate: vi.fn(), + mockRegisterUpdateListener: vi.fn(), + mockRegisterCommand: vi.fn(), + mockRead: vi.fn(), + mockSetLinkAnchorElement: vi.fn(), + mockSelectionNode: { + getParent: vi.fn<() => MockSelectionParent>(() => null), + }, + mockSelection: { + anchor: { + getNode: vi.fn(), + }, + focus: { + getNode: vi.fn(), + }, + isBackward: vi.fn(() => false), + clone: vi.fn(() => 'cloned-selection'), + }, + mockPatchStyleText: vi.fn(), + mockSetSelection: vi.fn(), + mockSelectionFontSize: vi.fn(), + mockIsRangeSelection: vi.fn(() => true), + mockSelectedIsBullet: vi.fn(() => false), + mockSetBlocksType: vi.fn(), +})) + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: () => ([{ + dispatchCommand: mockDispatchCommand, + update: mockEditorUpdate, + registerUpdateListener: mockRegisterUpdateListener, + registerCommand: mockRegisterCommand, + getEditorState: () => ({ + read: mockRead, + }), + }]), +})) + +vi.mock('@lexical/link', () => ({ + $isLinkNode: (node: unknown) => Boolean(node && typeof node === 'object' && 'isLink' in (node as object)), + TOGGLE_LINK_COMMAND: 'toggle-link-command', +})) + +vi.mock('@lexical/list', () => ({ + INSERT_UNORDERED_LIST_COMMAND: 'insert-unordered-list-command', +})) + +vi.mock('@lexical/selection', () => ({ + $getSelectionStyleValueForProperty: () => mockSelectionFontSize(), + $isAtNodeEnd: () => false, + $patchStyleText: mockPatchStyleText, + $setBlocksType: mockSetBlocksType, +})) + +vi.mock('@lexical/utils', () => ({ + mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()), +})) + +vi.mock('lexical', () => ({ + $createParagraphNode: () => ({ type: 'paragraph' }), + $getSelection: () => mockSelection, + $isRangeSelection: () => mockIsRangeSelection(), + $setSelection: mockSetSelection, + COMMAND_PRIORITY_CRITICAL: 4, + FORMAT_TEXT_COMMAND: 'format-text-command', + SELECTION_CHANGE_COMMAND: 'selection-change-command', +})) + +vi.mock('../../store', () => ({ + useNoteEditorStore: () => ({ + getState: () => ({ + selectedIsBullet: mockSelectedIsBullet(), + setLinkAnchorElement: mockSetLinkAnchorElement, + }), + }), +})) + +describe('note toolbar hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEditorUpdate.mockImplementation((callback) => { + callback() + }) + mockRegisterUpdateListener.mockImplementation((listener) => { + listener({}) + return vi.fn() + }) + mockRegisterCommand.mockImplementation((_command, listener) => { + listener() + return vi.fn() + }) + mockRead.mockImplementation((callback) => { + callback() + }) + mockSelectionFontSize.mockReturnValue('16px') + mockIsRangeSelection.mockReturnValue(true) + mockSelectedIsBullet.mockReturnValue(false) + mockSelection.anchor.getNode.mockReturnValue(mockSelectionNode) + mockSelection.focus.getNode.mockReturnValue(mockSelectionNode) + mockSelectionNode.getParent.mockReturnValue(null) + }) + + describe('useCommand', () => { + it('should dispatch text formatting commands directly', () => { + const { result } = renderHook(() => useCommand()) + + result.current.handleCommand('bold') + result.current.handleCommand('italic') + result.current.handleCommand('strikethrough') + + expect(mockDispatchCommand).toHaveBeenNthCalledWith(1, 'format-text-command', 'bold') + expect(mockDispatchCommand).toHaveBeenNthCalledWith(2, 'format-text-command', 'italic') + expect(mockDispatchCommand).toHaveBeenNthCalledWith(3, 'format-text-command', 'strikethrough') + }) + + it('should open link editing when current selection is not already a link', () => { + const { result } = renderHook(() => useCommand()) + + result.current.handleCommand('link') + + expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', '') + expect(mockSetLinkAnchorElement).toHaveBeenCalledWith(true) + }) + + it('should remove the link when the current selection is already within a link node', () => { + mockSelectionNode.getParent.mockReturnValue({ isLink: true }) + const { result } = renderHook(() => useCommand()) + + result.current.handleCommand('link') + + expect(mockDispatchCommand).toHaveBeenCalledWith('toggle-link-command', null) + expect(mockSetLinkAnchorElement).toHaveBeenCalledWith() + }) + + it('should ignore link commands when the selection is not a range', () => { + mockIsRangeSelection.mockReturnValue(false) + const { result } = renderHook(() => useCommand()) + + result.current.handleCommand('link') + + expect(mockDispatchCommand).not.toHaveBeenCalled() + expect(mockSetLinkAnchorElement).not.toHaveBeenCalled() + }) + + it('should toggle bullet formatting on and off', () => { + const { result, rerender } = renderHook(() => useCommand()) + + result.current.handleCommand('bullet') + expect(mockDispatchCommand).toHaveBeenCalledWith('insert-unordered-list-command', undefined) + + mockSelectedIsBullet.mockReturnValue(true) + rerender() + + result.current.handleCommand('bullet') + expect(mockSetBlocksType).toHaveBeenCalledWith(mockSelection, expect.any(Function)) + }) + }) + + describe('useFontSize', () => { + it('should expose font size state and update selection styling', () => { + const { result } = renderHook(() => useFontSize()) + + expect(result.current.fontSize).toBe('16px') + + result.current.handleFontSize('20px') + expect(mockPatchStyleText).toHaveBeenCalledWith(mockSelection, { 'font-size': '20px' }) + }) + + it('should preserve the current selection when opening the selector', () => { + const { result } = renderHook(() => useFontSize()) + + result.current.handleOpenFontSizeSelector(true) + + expect(mockSetSelection).toHaveBeenCalledWith('cloned-selection') + }) + + it('should keep the default font size and avoid patching styles when the selection is not a range', () => { + mockIsRangeSelection.mockReturnValue(false) + const { result } = renderHook(() => useFontSize()) + + expect(result.current.fontSize).toBe('12px') + + result.current.handleFontSize('20px') + expect(mockPatchStyleText).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts index 39f6a9dc5c..2c233fecda 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts @@ -27,55 +27,72 @@ import { import { useNoteEditorStore } from '../store' import { getSelectedNode } from '../utils' +const DEFAULT_FONT_SIZE = '12px' + +const updateFontSizeFromSelection = (setFontSize: (fontSize: string) => void) => { + const selection = $getSelection() + if ($isRangeSelection(selection)) + setFontSize($getSelectionStyleValueForProperty(selection, 'font-size', DEFAULT_FONT_SIZE)) +} + +const toggleLink = ( + editor: ReturnType[0], + noteEditorStore: ReturnType, +) => { + editor.update(() => { + const selection = $getSelection() + + if (!$isRangeSelection(selection)) + return + + const node = getSelectedNode(selection) + const parent = node.getParent() + const { setLinkAnchorElement } = noteEditorStore.getState() + + if ($isLinkNode(parent) || $isLinkNode(node)) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + setLinkAnchorElement() + return + } + + editor.dispatchCommand(TOGGLE_LINK_COMMAND, '') + setLinkAnchorElement(true) + }) +} + +const toggleBullet = ( + editor: ReturnType[0], + selectedIsBullet: boolean, +) => { + if (!selectedIsBullet) { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) + return + } + + editor.update(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) + $setBlocksType(selection, () => $createParagraphNode()) + }) +} + export const useCommand = () => { const [editor] = useLexicalComposerContext() const noteEditorStore = useNoteEditorStore() const handleCommand = useCallback((type: string) => { - if (type === 'bold') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') - - if (type === 'italic') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') - - if (type === 'strikethrough') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') + if (type === 'bold' || type === 'italic' || type === 'strikethrough') { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, type) + return + } if (type === 'link') { - editor.update(() => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const node = getSelectedNode(selection) - const parent = node.getParent() - const { setLinkAnchorElement } = noteEditorStore.getState() - - if ($isLinkNode(parent) || $isLinkNode(node)) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) - setLinkAnchorElement() - } - else { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, '') - setLinkAnchorElement(true) - } - } - }) + toggleLink(editor, noteEditorStore) + return } - if (type === 'bullet') { - const { selectedIsBullet } = noteEditorStore.getState() - - if (selectedIsBullet) { - editor.update(() => { - const selection = $getSelection() - if ($isRangeSelection(selection)) - $setBlocksType(selection, () => $createParagraphNode()) - }) - } - else { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) - } - } + if (type === 'bullet') + toggleBullet(editor, noteEditorStore.getState().selectedIsBullet) }, [editor, noteEditorStore]) return { @@ -85,7 +102,7 @@ export const useCommand = () => { export const useFontSize = () => { const [editor] = useLexicalComposerContext() - const [fontSize, setFontSize] = useState('12px') + const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE) const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false) const handleFontSize = useCallback((fontSize: string) => { @@ -113,24 +130,13 @@ export const useFontSize = () => { return mergeRegister( editor.registerUpdateListener(() => { editor.getEditorState().read(() => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px') - setFontSize(fontSize) - } + updateFontSizeFromSelection(setFontSize) }) }), editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px') - setFontSize(fontSize) - } - + updateFontSizeFromSelection(setFontSize) return false }, COMMAND_PRIORITY_CRITICAL, diff --git a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0beae8b4b5 --- /dev/null +++ b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx @@ -0,0 +1,424 @@ +import type { ReactElement } from 'react' +import type { Shape } from '@/app/components/workflow/store/workflow' +import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store/workflow' +import EnvPanel from '../index' + +type MockWorkflowNode = { + id: string + data?: Record +} + +const { + mockDoSyncWorkflowDraft, + mockGetNodes, + mockSetNodes, + mockFindUsedVarNodes, + mockUpdateNodeVars, + mockVariableTriggerState, +} = vi.hoisted(() => ({ + mockDoSyncWorkflowDraft: vi.fn(() => Promise.resolve()), + mockGetNodes: vi.fn<() => MockWorkflowNode[]>(() => []), + mockSetNodes: vi.fn<(nodes: MockWorkflowNode[]) => void>(), + mockFindUsedVarNodes: vi.fn<(selector: string[], nodes: MockWorkflowNode[]) => MockWorkflowNode[]>(() => []), + mockUpdateNodeVars: vi.fn<(node: MockWorkflowNode, currentSelector: string[], nextSelector: string[]) => MockWorkflowNode>((node, _currentSelector, nextSelector) => ({ + ...node, + data: { + ...node.data, + nextSelector, + }, + })), + mockVariableTriggerState: { + savePayload: undefined as EnvironmentVariable | undefined, + }, +})) + +vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: mockDoSyncWorkflowDraft, + }), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + }), + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ + findUsedVarNodes: mockFindUsedVarNodes, + updateNodeVars: mockUpdateNodeVars, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({ + default: ({ + isShow, + onCancel, + onConfirm, + }: { + isShow: boolean + onCancel: () => void + onConfirm: () => void + }) => isShow + ? ( +
+ + +
+ ) + : null, +})) + +vi.mock('@/app/components/workflow/panel/env-panel/env-item', () => ({ + default: ({ + env, + onEdit, + onDelete, + }: { + env: EnvironmentVariable + onEdit: (env: EnvironmentVariable) => void + onDelete: (env: EnvironmentVariable) => void + }) => ( +
+ {env.name} + + +
+ ), +})) + +vi.mock('@/app/components/workflow/panel/env-panel/variable-trigger', () => ({ + default: ({ + open, + env, + onClose, + onSave, + setOpen, + }: { + open: boolean + env?: EnvironmentVariable + onClose: () => void + onSave: (env: EnvironmentVariable) => Promise + setOpen: (open: boolean) => void + }) => ( +
+ + Variable trigger: + {open ? 'open' : 'closed'} + : + {env?.name || 'new'} + + + + +
+ ), +})) + +const createEnv = (overrides: Partial = {}): EnvironmentVariable => ({ + id: 'env-1', + name: 'api_key', + value: '[__HIDDEN__]', + value_type: 'secret', + description: 'secret description', + ...overrides, +}) + +const renderWithProviders = ( + ui: ReactElement, + storeState: Partial = {}, +) => { + const store = createWorkflowStore({}) + store.setState(storeState) + + return { + store, + ...render( + + {ui} + , + ), + } +} + +describe('EnvPanel container', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetNodes.mockReturnValue([]) + mockFindUsedVarNodes.mockReturnValue([]) + mockVariableTriggerState.savePayload = undefined + }) + + it('should close the panel from the header action', async () => { + const user = userEvent.setup() + const { container, store } = renderWithProviders(, { + environmentVariables: [], + }) + + await user.click(container.querySelector('.cursor-pointer') as HTMLElement) + + expect(store.getState().showEnvPanel).toBe(false) + }) + + it('should add variables and normalize secret values after syncing', async () => { + const user = userEvent.setup() + const { store } = renderWithProviders(, { + environmentVariables: [], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: 'env-created', + name: 'created_name', + value: 'created-value', + }), + ]) + }) + + it('should delete unused variables and sync draft changes', async () => { + const user = userEvent.setup() + const env = createEnv({ value_type: 'string', value: 'plain-text' }) + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: `Delete ${env.name}` })) + + expect(store.getState().environmentVariables).toEqual([]) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) + + it('should add secret variables, persist masked secrets, and sanitize the stored env value', async () => { + const user = userEvent.setup() + mockVariableTriggerState.savePayload = createEnv({ + id: 'env-secret', + name: 'secret_key', + value: '1234567890', + value_type: 'secret', + }) + + const { store } = renderWithProviders(, { + environmentVariables: [], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + await waitFor(() => { + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: 'env-secret', + name: 'secret_key', + value: '[__HIDDEN__]', + value_type: 'secret', + }), + ]) + }) + expect(store.getState().envSecrets).toEqual({ + 'env-secret': '123456************90', + }) + }) + + it('should clear the current variable when the variable modal closes', async () => { + const user = userEvent.setup() + const env = createEnv({ value_type: 'string', value: 'plain-text' }) + + renderWithProviders(, { + environmentVariables: [env], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: `Edit ${env.name}` })) + expect(screen.getByText('Variable trigger:open:api_key')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close variable modal' })) + + expect(screen.getByText('Variable trigger:open:new')).toBeInTheDocument() + }) + + it('should rename existing secret variables and update affected nodes without re-saving unchanged secrets', async () => { + const user = userEvent.setup() + const env = createEnv() + mockVariableTriggerState.savePayload = createEnv({ + id: env.id, + name: 'renamed_key', + value: '[__HIDDEN__]', + value_type: 'secret', + }) + mockFindUsedVarNodes.mockReturnValue([{ id: 'node-1' }]) + mockGetNodes.mockReturnValue([ + { id: 'node-1', data: { nextSelector: ['env', env.name] } }, + { id: 'node-2', data: { untouched: true } }, + ]) + + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: { + [env.id]: '[__HIDDEN__]', + }, + }) + + await user.click(screen.getByRole('button', { name: `Edit ${env.name}` })) + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + await waitFor(() => { + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: env.id, + name: 'renamed_key', + value: '[__HIDDEN__]', + value_type: 'secret', + }), + ]) + }) + expect(store.getState().envSecrets).toEqual({ + [env.id]: '[__HIDDEN__]', + }) + expect(mockUpdateNodeVars).toHaveBeenCalledWith( + expect.objectContaining({ id: 'node-1' }), + ['env', env.name], + ['env', 'renamed_key'], + ) + expect(mockSetNodes).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ + nextSelector: ['env', 'renamed_key'], + }), + }), + expect.objectContaining({ id: 'node-2' }), + ]) + }) + + it('should convert edited plain variables into secrets and store the masked secret value', async () => { + const user = userEvent.setup() + const env = createEnv({ value_type: 'string', value: 'plain-text' }) + mockVariableTriggerState.savePayload = createEnv({ + id: env.id, + name: env.name, + value: 'abcdef123456', + value_type: 'secret', + }) + + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: `Edit ${env.name}` })) + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + await waitFor(() => { + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: env.id, + value: '[__HIDDEN__]', + value_type: 'secret', + }), + ]) + }) + expect(store.getState().envSecrets).toEqual({ + [env.id]: 'abcdef************56', + }) + }) + + it('should persist a new masked secret when an existing secret variable changes value', async () => { + const user = userEvent.setup() + const env = createEnv() + mockVariableTriggerState.savePayload = createEnv({ + id: env.id, + name: env.name, + value: 'updated-secret-99', + value_type: 'secret', + }) + + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: { + [env.id]: '[__HIDDEN__]', + }, + }) + + await user.click(screen.getByRole('button', { name: `Edit ${env.name}` })) + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + await waitFor(() => { + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: env.id, + value: '[__HIDDEN__]', + value_type: 'secret', + }), + ]) + }) + expect(store.getState().envSecrets).toEqual({ + [env.id]: 'update************99', + }) + }) + + it('should require confirmation before deleting affected secret variables', async () => { + const user = userEvent.setup() + const env = createEnv() + mockFindUsedVarNodes.mockReturnValue([{ id: 'node-1' }]) + mockGetNodes.mockReturnValue([ + { id: 'node-1', data: { nextSelector: ['env', env.name] } }, + { id: 'node-2', data: { untouched: true } }, + ]) + + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: { + [env.id]: 'abcdef************56', + }, + }) + + await user.click(screen.getByRole('button', { name: `Delete ${env.name}` })) + expect(screen.getByRole('button', { name: 'Cancel remove' })).toBeInTheDocument() + expect(store.getState().environmentVariables).toHaveLength(1) + + await user.click(screen.getByRole('button', { name: 'Cancel remove' })) + expect(screen.queryByRole('button', { name: 'Confirm remove' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: `Delete ${env.name}` })) + await user.click(screen.getByRole('button', { name: 'Confirm remove' })) + + await waitFor(() => { + expect(store.getState().environmentVariables).toEqual([]) + }) + expect(store.getState().envSecrets).toEqual({}) + expect(mockUpdateNodeVars).toHaveBeenCalledWith( + expect.objectContaining({ id: 'node-1' }), + ['env', env.name], + [], + ) + }) +}) diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index 5213a5dba1..324552ebd9 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -19,6 +19,79 @@ import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable- import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' +const HIDDEN_SECRET_VALUE = '[__HIDDEN__]' + +const formatSecret = (secret: string) => { + return secret.length > 8 ? `${secret.slice(0, 6)}************${secret.slice(-2)}` : '********************' +} + +const sanitizeSecretValue = (env: EnvironmentVariable) => { + return env.value_type === 'secret' + ? { ...env, value: HIDDEN_SECRET_VALUE } + : env +} + +const useEnvPanelActions = ({ + store, + envSecrets, + updateEnvList, + setEnvSecrets, + doSyncWorkflowDraft, +}: { + store: ReturnType + envSecrets: Record + updateEnvList: (envList: EnvironmentVariable[]) => void + setEnvSecrets: (envSecrets: Record) => void + doSyncWorkflowDraft: () => Promise +}) => { + const getAffectedNodes = useCallback((env: EnvironmentVariable) => { + const allNodes = store.getState().getNodes() + return findUsedVarNodes( + ['env', env.name], + allNodes, + ) + }, [store]) + + const updateAffectedNodes = useCallback((currentEnv: EnvironmentVariable, nextSelector: string[]) => { + const { getNodes, setNodes } = store.getState() + const affectedNodes = getAffectedNodes(currentEnv) + const nextNodes = getNodes().map((node) => { + if (affectedNodes.find(affectedNode => affectedNode.id === node.id)) + return updateNodeVars(node, ['env', currentEnv.name], nextSelector) + + return node + }) + setNodes(nextNodes) + }, [getAffectedNodes, store]) + + const syncEnvList = useCallback(async (nextEnvList: EnvironmentVariable[]) => { + updateEnvList(nextEnvList) + await doSyncWorkflowDraft() + updateEnvList(nextEnvList.map(sanitizeSecretValue)) + }, [doSyncWorkflowDraft, updateEnvList]) + + const saveSecretValue = useCallback((env: EnvironmentVariable) => { + setEnvSecrets({ + ...envSecrets, + [env.id]: formatSecret(String(env.value)), + }) + }, [envSecrets, setEnvSecrets]) + + const removeEnvSecret = useCallback((envId: string) => { + const nextSecrets = { ...envSecrets } + delete nextSecrets[envId] + setEnvSecrets(nextSecrets) + }, [envSecrets, setEnvSecrets]) + + return { + getAffectedNodes, + updateAffectedNodes, + syncEnvList, + saveSecretValue, + removeEnvSecret, + } +} + const EnvPanel = () => { const { t } = useTranslation() const store = useStoreApi() @@ -28,123 +101,87 @@ const EnvPanel = () => { const updateEnvList = useStore(s => s.setEnvironmentVariables) const setEnvSecrets = useStore(s => s.setEnvSecrets) const { doSyncWorkflowDraft } = useNodesSyncDraft() + const { + getAffectedNodes, + updateAffectedNodes, + syncEnvList, + saveSecretValue, + removeEnvSecret, + } = useEnvPanelActions({ + store, + envSecrets, + updateEnvList, + setEnvSecrets, + doSyncWorkflowDraft, + }) const [showVariableModal, setShowVariableModal] = useState(false) const [currentVar, setCurrentVar] = useState() - const [showRemoveVarConfirm, setShowRemoveConfirm] = useState(false) + const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false) const [cacheForDelete, setCacheForDelete] = useState() - const formatSecret = (s: string) => { - return s.length > 8 ? `${s.slice(0, 6)}************${s.slice(-2)}` : '********************' - } - - const getEffectedNodes = useCallback((env: EnvironmentVariable) => { - const { getNodes } = store.getState() - const allNodes = getNodes() - return findUsedVarNodes( - ['env', env.name], - allNodes, - ) - }, [store]) - - const removeUsedVarInNodes = useCallback((env: EnvironmentVariable) => { - const { getNodes, setNodes } = store.getState() - const effectedNodes = getEffectedNodes(env) - const newNodes = getNodes().map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['env', env.name], []) - - return node - }) - setNodes(newNodes) - }, [getEffectedNodes, store]) - const handleEdit = (env: EnvironmentVariable) => { setCurrentVar(env) setShowVariableModal(true) } const handleDelete = useCallback((env: EnvironmentVariable) => { - removeUsedVarInNodes(env) + updateAffectedNodes(env, []) updateEnvList(envList.filter(e => e.id !== env.id)) setCacheForDelete(undefined) - setShowRemoveConfirm(false) + setShowRemoveVarConfirm(false) doSyncWorkflowDraft() - if (env.value_type === 'secret') { - const newMap = { ...envSecrets } - delete newMap[env.id] - setEnvSecrets(newMap) - } - }, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList]) + if (env.value_type === 'secret') + removeEnvSecret(env.id) + }, [doSyncWorkflowDraft, envList, removeEnvSecret, updateAffectedNodes, updateEnvList]) const deleteCheck = useCallback((env: EnvironmentVariable) => { - const effectedNodes = getEffectedNodes(env) - if (effectedNodes.length > 0) { + const affectedNodes = getAffectedNodes(env) + if (affectedNodes.length > 0) { setCacheForDelete(env) - setShowRemoveConfirm(true) + setShowRemoveVarConfirm(true) } else { handleDelete(env) } - }, [getEffectedNodes, handleDelete]) + }, [getAffectedNodes, handleDelete]) const handleSave = useCallback(async (env: EnvironmentVariable) => { - // add env let newEnv = env if (!currentVar) { - if (env.value_type === 'secret') { - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) - } - const newList = [env, ...envList] - updateEnvList(newList) - await doSyncWorkflowDraft() - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) + if (env.value_type === 'secret') + saveSecretValue(env) + + await syncEnvList([env, ...envList]) return } - else if (currentVar.value_type === 'secret') { + + if (currentVar.value_type === 'secret') { if (env.value_type === 'secret') { if (envSecrets[currentVar.id] !== env.value) { newEnv = env - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) + saveSecretValue(env) } else { - newEnv = { ...env, value: '[__HIDDEN__]' } + newEnv = sanitizeSecretValue(env) } } } - else { - if (env.value_type === 'secret') { - newEnv = env - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) - } + else if (env.value_type === 'secret') { + saveSecretValue(env) } - const newList = envList.map(e => e.id === currentVar.id ? newEnv : e) - updateEnvList(newList) - // side effects of rename env - if (currentVar.name !== env.name) { - const { getNodes, setNodes } = store.getState() - const effectedNodes = getEffectedNodes(currentVar) - const newNodes = getNodes().map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['env', currentVar.name], ['env', env.name]) - return node - }) - setNodes(newNodes) - } - await doSyncWorkflowDraft() - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) - }, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList]) + const newList = envList.map(e => e.id === currentVar.id ? newEnv : e) + if (currentVar.name !== env.name) + updateAffectedNodes(currentVar, ['env', env.name]) + + await syncEnvList(newList) + }, [currentVar, envList, envSecrets, saveSecretValue, syncEnvList, updateAffectedNodes]) + + const handleVariableModalClose = () => { + setCurrentVar(undefined) + } return (
{ className="flex h-6 w-6 cursor-pointer items-center justify-center" onClick={() => setShowEnvPanel(false)} > + {/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
@@ -170,7 +208,7 @@ const EnvPanel = () => { setOpen={setShowVariableModal} env={currentVar} onSave={handleSave} - onClose={() => setCurrentVar(undefined)} + onClose={handleVariableModalClose} />
@@ -185,7 +223,7 @@ const EnvPanel = () => {
setShowRemoveConfirm(false)} + onCancel={() => setShowRemoveVarConfirm(false)} onConfirm={() => cacheForDelete && handleDelete(cacheForDelete)} /> diff --git a/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx new file mode 100644 index 0000000000..e497b1988e --- /dev/null +++ b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx @@ -0,0 +1,189 @@ +import type { IterationDurationMap, NodeTracing } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum, NodeRunningStatus } from '../../../types' +import IterationLogTrigger from '../iteration-log-trigger' + +const createNodeTracing = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'iteration-node', + node_type: BlockEnum.Iteration, + title: 'Iteration', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: NodeRunningStatus.Succeeded, + error: '', + elapsed_time: 0.2, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + ...overrides, +}) + +const createExecutionMetadata = (overrides: Partial> = {}) => ({ + total_tokens: 0, + total_price: 0, + currency: 'USD', + ...overrides, +}) + +describe('IterationLogTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Structured Detail Handling', () => { + it('should reconstruct structured iteration groups from execution metadata and include failed missing details', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const iterationDurationMap: IterationDurationMap = { 'parallel-1': 1.1, '1': 2.2 } + const missingFailedIteration = [ + createNodeTracing({ + id: 'failed-step', + status: NodeRunningStatus.Failed, + execution_metadata: createExecutionMetadata({ + iteration_index: 2, + }), + }), + ] + const allExecutions = [ + createNodeTracing({ + id: 'parallel-step', + execution_metadata: createExecutionMetadata({ + parallel_mode_run_id: 'parallel-1', + }), + }), + createNodeTracing({ + id: 'serial-step', + execution_metadata: createExecutionMetadata({ + iteration_id: 'iteration-node', + iteration_index: 1, + }), + }), + ] + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith( + [ + [allExecutions[0]], + [allExecutions[1]], + missingFailedIteration, + ], + iterationDurationMap, + ) + }) + + it('should fall back to details and metadata length when duration map is unavailable', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const detailList = [[createNodeTracing({ id: 'detail-1' })]] + + render( + , + ) + + expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.iteration/ })).toBeInTheDocument() + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith(detailList, {}) + }) + + it('should return an empty structured list when duration map exists without executions', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const iterationDurationMap: IterationDurationMap = { orphaned: 1.5 } + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith([], iterationDurationMap) + }) + + it('should count failed iterations from allExecutions and ignore unmatched duration map keys', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const iterationDurationMap: IterationDurationMap = { orphaned: 0.6, 1: 1.1 } + const allExecutions = [ + createNodeTracing({ + id: 'failed-serial-step', + status: NodeRunningStatus.Failed, + execution_metadata: createExecutionMetadata({ + iteration_id: 'iteration-node', + iteration_index: 1, + }), + }), + ] + + render( + , + ) + + expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i })).toBeInTheDocument() + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith([[allExecutions[0]]], iterationDurationMap) + }) + }) +}) diff --git a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx index 63043e51b7..52ebb29ff5 100644 --- a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx +++ b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx @@ -13,6 +13,54 @@ type IterationLogTriggerProps = { allExecutions?: NodeTracing[] onShowIterationResultList: (iterationResultList: NodeTracing[][], iterationResultDurationMap: IterationDurationMap) => void } + +const getIterationDurationMap = (nodeInfo: NodeTracing) => { + return nodeInfo.iterDurationMap || nodeInfo.execution_metadata?.iteration_duration_map || {} +} + +const getDisplayIterationCount = (nodeInfo: NodeTracing) => { + const iterationDurationMap = nodeInfo.execution_metadata?.iteration_duration_map + if (iterationDurationMap) + return Object.keys(iterationDurationMap).length + if (nodeInfo.details?.length) + return nodeInfo.details.length + return nodeInfo.metadata?.iterator_length ?? 0 +} + +const getFailedIterationIndices = ( + details: NodeTracing[][] | undefined, + nodeInfo: NodeTracing, + allExecutions?: NodeTracing[], +) => { + if (!details?.length) + return new Set() + + const failedIterationIndices = new Set() + + details.forEach((iteration, index) => { + if (!iteration.some(item => item.status === NodeRunningStatus.Failed)) + return + + const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index + failedIterationIndices.add(iterationIndex) + }) + + if (!nodeInfo.execution_metadata?.iteration_duration_map || !allExecutions) + return failedIterationIndices + + allExecutions.forEach((execution) => { + if ( + execution.execution_metadata?.iteration_id === nodeInfo.node_id + && execution.status === NodeRunningStatus.Failed + && execution.execution_metadata?.iteration_index !== undefined + ) { + failedIterationIndices.add(execution.execution_metadata.iteration_index) + } + }) + + return failedIterationIndices +} + const IterationLogTrigger = ({ nodeInfo, allExecutions, @@ -20,7 +68,7 @@ const IterationLogTrigger = ({ }: IterationLogTriggerProps) => { const { t } = useTranslation() - const filterNodesForInstance = (key: string): NodeTracing[] => { + const getNodesForInstance = (key: string): NodeTracing[] => { if (!allExecutions) return [] @@ -43,97 +91,59 @@ const IterationLogTrigger = ({ return [] } + const getStructuredIterationList = () => { + const iterationNodeMeta = nodeInfo.execution_metadata + + if (!iterationNodeMeta?.iteration_duration_map) + return nodeInfo.details || [] + + const structuredList = Object.keys(iterationNodeMeta.iteration_duration_map) + .map(getNodesForInstance) + .filter(branchNodes => branchNodes.length > 0) + + if (!allExecutions || !nodeInfo.details?.length) + return structuredList + + const existingIterationIndices = new Set() + structuredList.forEach((iteration) => { + iteration.forEach((node) => { + if (node.execution_metadata?.iteration_index !== undefined) + existingIterationIndices.add(node.execution_metadata.iteration_index) + }) + }) + + nodeInfo.details.forEach((iteration, index) => { + if ( + !existingIterationIndices.has(index) + && iteration.some(node => node.status === NodeRunningStatus.Failed) + ) { + structuredList.push(iteration) + } + }) + + return structuredList.sort((a, b) => { + const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0 + const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0 + return aIndex - bIndex + }) + } + const handleOnShowIterationDetail = (e: React.MouseEvent) => { e.stopPropagation() e.nativeEvent.stopImmediatePropagation() - const iterationNodeMeta = nodeInfo.execution_metadata - const iterDurationMap = nodeInfo?.iterDurationMap || iterationNodeMeta?.iteration_duration_map || {} - - let structuredList: NodeTracing[][] = [] - if (iterationNodeMeta?.iteration_duration_map) { - const instanceKeys = Object.keys(iterationNodeMeta.iteration_duration_map) - structuredList = instanceKeys - .map(key => filterNodesForInstance(key)) - .filter(branchNodes => branchNodes.length > 0) - - // Also include failed iterations that might not be in duration map - if (allExecutions && nodeInfo.details?.length) { - const existingIterationIndices = new Set() - structuredList.forEach((iteration) => { - iteration.forEach((node) => { - if (node.execution_metadata?.iteration_index !== undefined) - existingIterationIndices.add(node.execution_metadata.iteration_index) - }) - }) - - // Find failed iterations that are not in the structured list - nodeInfo.details.forEach((iteration, index) => { - if (!existingIterationIndices.has(index) && iteration.some(node => node.status === NodeRunningStatus.Failed)) - structuredList.push(iteration) - }) - - // Sort by iteration index to maintain order - structuredList.sort((a, b) => { - const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0 - const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0 - return aIndex - bIndex - }) - } - } - else if (nodeInfo.details?.length) { - structuredList = nodeInfo.details - } - - onShowIterationResultList(structuredList, iterDurationMap) + onShowIterationResultList(getStructuredIterationList(), getIterationDurationMap(nodeInfo)) } - let displayIterationCount = 0 - const iterMap = nodeInfo.execution_metadata?.iteration_duration_map - if (iterMap) - displayIterationCount = Object.keys(iterMap).length - else if (nodeInfo.details?.length) - displayIterationCount = nodeInfo.details.length - else if (nodeInfo.metadata?.iterator_length) - displayIterationCount = nodeInfo.metadata.iterator_length - - const getErrorCount = (details: NodeTracing[][] | undefined, iterationNodeMeta?: any) => { - if (!details || details.length === 0) - return 0 - - // Use Set to track failed iteration indices to avoid duplicate counting - const failedIterationIndices = new Set() - - // Collect failed iteration indices from details - details.forEach((iteration, index) => { - if (iteration.some(item => item.status === NodeRunningStatus.Failed)) { - // Try to get iteration index from first node, fallback to array index - const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index - failedIterationIndices.add(iterationIndex) - } - }) - - // If allExecutions exists, check for additional failed iterations - if (iterationNodeMeta?.iteration_duration_map && allExecutions) { - // Find all failed iteration nodes - allExecutions.forEach((exec) => { - if (exec.execution_metadata?.iteration_id === nodeInfo.node_id - && exec.status === NodeRunningStatus.Failed - && exec.execution_metadata?.iteration_index !== undefined) { - failedIterationIndices.add(exec.execution_metadata.iteration_index) - } - }) - } - - return failedIterationIndices.size - } - const errorCount = getErrorCount(nodeInfo.details, nodeInfo.execution_metadata) + const displayIterationCount = getDisplayIterationCount(nodeInfo) + const errorCount = getFailedIterationIndices(nodeInfo.details, nodeInfo, allExecutions).size return ( ) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index c02d302f09..b38a0f3f9d 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -6587,9 +6587,6 @@ "app/components/workflow/block-selector/tabs.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 } }, "app/components/workflow/block-selector/tool-picker.tsx": { @@ -6721,9 +6718,6 @@ }, "react-refresh/only-export-components": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 } }, "app/components/workflow/header/undo-redo.tsx": { @@ -6813,11 +6807,6 @@ "count": 1 } }, - "app/components/workflow/hooks/use-workflow-interactions.ts": { - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts": { "ts/no-explicit-any": { "count": 1 @@ -7795,9 +7784,6 @@ "app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": { "react-refresh/only-export-components": { "count": 2 - }, - "ts/no-explicit-any": { - "count": 8 } }, "app/components/workflow/nodes/human-input/node.tsx": { @@ -8460,7 +8446,7 @@ }, "app/components/workflow/nodes/loop/use-single-run-form-params.ts": { "ts/no-explicit-any": { - "count": 4 + "count": 3 } }, "app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": { @@ -9177,9 +9163,6 @@ "tailwindcss/enforce-consistent-class-order": { "count": 1 }, - "ts/no-explicit-any": { - "count": 1 - }, "unicorn/prefer-number-properties": { "count": 1 }