test(workflow): add helper specs and raise targeted workflow coverage (#33995)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-03-24 17:51:07 +08:00 committed by GitHub
parent 67d5c9d148
commit a408a5d87e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 9402 additions and 2507 deletions

View File

@ -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<string, string>, name: string }> | undefined,
customTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
workflowTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
mcpTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
},
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
}: {
children: React.ReactNode
popupContent: React.ReactNode
}) => (
<div>
<span>{popupContent}</span>
{children}
</div>
),
}))
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: () => <div>start-content</div>,
}))
vi.mock('../blocks', () => ({
default: () => <div>blocks-content</div>,
}))
vi.mock('../data-sources', () => ({
default: () => <div>sources-content</div>,
}))
vi.mock('../all-tools', () => ({
default: (props: {
buildInTools: Array<{ icon: string | Record<string, string> }>
showFeatured: boolean
featuredLoading: boolean
onFeaturedInstallSuccess: () => Promise<void>
}) => (
<div>
tools-content
{props.buildInTools.map((tool, index) => (
<span key={index}>
{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}
</span>
))}
<span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span>
<span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span>
<button onClick={() => props.onFeaturedInstallSuccess()}>Install featured tool</button>
</div>
),
}))
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: <div>filter</div>,
}
it('should render start content and disabled tab tooltip text', () => {
render(<Tabs {...baseProps} />)
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(
<Tabs
{...baseProps}
activeTab={TabsEnum.Tools}
onActiveTabChange={onActiveTabChange}
/>,
)
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(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(mockSetState).toHaveBeenCalled()
})
it('should ignore clicks on disabled and already active tabs', async () => {
const user = userEvent.setup()
const onActiveTabChange = vi.fn()
render(
<Tabs
{...baseProps}
activeTab={TabsEnum.Start}
onActiveTabChange={onActiveTabChange}
/>,
)
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(
<Tabs
{...baseProps}
activeTab={TabsEnum.Sources}
dataSources={[{ name: 'dataset', icon: '/dataset.svg' } as never]}
/>,
)
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(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
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(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(screen.getByText('object-icon')).toBeInTheDocument()
const updateState = mockSetState.mock.calls[0][0] as (state: {
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>, name: string }>
}) => {
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
mcpTools?: Array<{ icon: string | Record<string, string>, 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(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
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(
<Tabs
{...baseProps}
activeTab={TabsEnum.Tools}
/>,
)
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(
<Tabs
{...baseProps}
activeTab={TabsEnum.Start}
noBlocks
forceShowStartContent
/>,
)
expect(screen.getByText('start-content')).toBeInTheDocument()
})
})

View File

@ -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<typeof state> = {}
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 (
<Tooltip
key={tab.key}
position="top"
popupClassName="max-w-[200px]"
popupContent={disabledTip}
>
<div
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</div>
</Tooltip>
)
}
return (
<div
key={tab.key}
className={className}
aria-disabled={tab.disabled}
onClick={handleClick}
>
{tab.name}
</div>
)
}
const Tabs: FC<TabsProps> = ({
activeTab,
onActiveTabChange,
@ -71,51 +187,21 @@ const Tabs: FC<TabsProps> = ({
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<typeof state> = {}
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<TabsProps> = ({
...updates,
}
})
}, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools])
}, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore])
return (
<div onClick={e => e.stopPropagation()}>
@ -131,46 +217,15 @@ const Tabs: FC<TabsProps> = ({
!noBlocks && (
<div className="relative flex bg-background-section-burn pl-1 pt-1">
{
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 (
<Tooltip
key={tab.key}
position="top"
popupClassName="max-w-[200px]"
popupContent={t('tabs.startDisabledTip', { ns: 'workflow' })}
>
<div {...commonProps}>
{tab.name}
</div>
</Tooltip>
)
}
return (
<div
key={tab.key}
{...commonProps}
>
{tab.name}
</div>
)
})
tabs.map(tab => (
<TabHeaderItem
key={tab.key}
tab={tab}
activeTab={activeTab}
onActiveTabChange={onActiveTabChange}
disabledTip={disabledTip}
/>
))
}
</div>
)
@ -219,10 +274,10 @@ const Tabs: FC<TabsProps> = ({
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}

View File

@ -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[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
name: 'User Input',
icon: <span>icon</span>,
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(
<OptionRow
option={option}
shortcutKey="1"
onSelect={onSelect}
/>,
)
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(
<SingleOptionTrigger runSoleOption={runSoleOption}>
Open directly
</SingleOptionTrigger>,
)
await user.click(screen.getByText('Open directly'))
expect(runSoleOption).toHaveBeenCalledTimes(1)
rerender(
<SingleOptionTrigger runSoleOption={runSoleOption}>
<button onClick={originalOnClick}>Child trigger</button>
</SingleOptionTrigger>,
)
await user.click(screen.getByRole('button', { name: 'Child trigger' }))
expect(originalOnClick).toHaveBeenCalledTimes(1)
expect(runSoleOption).toHaveBeenCalledTimes(2)
rerender(
<SingleOptionTrigger runSoleOption={runSoleOption}>
<button
onClick={(event) => {
event.preventDefault()
originalOnClick()
}}
>
Prevented child
</button>
</SingleOptionTrigger>,
)
await user.click(screen.getByRole('button', { name: 'Prevented child' }))
expect(originalOnClick).toHaveBeenCalledTimes(2)
expect(runSoleOption).toHaveBeenCalledTimes(2)
})
})

View File

@ -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
}) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => <div>{children}</div>,
}))
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
name: 'User Input',
icon: <span>icon</span>,
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(
<TestRunMenu
options={{
userInput: createOption(),
triggers: [],
}}
onSelect={onSelect}
>
<button onClick={originalOnClick}>Run now</button>
</TestRunMenu>,
)
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<TestRunMenuRef>(null)
return (
<>
<button onClick={() => ref.current?.toggle()}>Toggle via ref</button>
<TestRunMenu
ref={ref}
options={{
userInput: createOption(),
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
}}
onSelect={onSelect}
>
<button>Open menu</button>
</TestRunMenu>
</>
)
}
render(<Harness />)
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(
<TestRunMenu
options={{
userInput: createOption({ enabled: false }),
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
}}
onSelect={vi.fn()}
>
<button>Open menu</button>
</TestRunMenu>,
)
await user.click(screen.getByRole('button', { name: 'Open menu' }))
expect(screen.queryByText('User Input')).not.toBeInTheDocument()
expect(screen.getByText('Webhook Trigger')).toBeInTheDocument()
})
})

View File

@ -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 (
<div
key={option.id}
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
onClick={() => onSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
{option.icon}
</div>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
)
}
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<HTMLElement>) => {
if (event?.defaultPrevented)
return
runSoleOption()
}
if (isValidElement(children)) {
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
const originalOnClick = childElement.props?.onClick
// eslint-disable-next-line react/no-clone-element
return cloneElement(childElement, {
onClick: (event: MouseEvent<HTMLElement>) => {
if (typeof originalOnClick === 'function')
originalOnClick(event)
if (event?.defaultPrevented)
return
runSoleOption()
},
})
}
return (
<span onClick={handleRunClick}>
{children}
</span>
)
}

View File

@ -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<TestRunMenuRef, TestRunMenuProps>(({
options,
onSelect,
@ -97,17 +99,7 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
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<TestRunMenuRef, TestRunMenuProps>(({
handleSelect(soleEnabledOption)
}, [handleSelect, soleEnabledOption])
useShortcutMenu({
open,
shortcutMappings,
handleSelect,
})
useImperativeHandle(ref, () => ({
toggle: () => {
if (hasSingleEnabledOption) {
@ -128,84 +126,17 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
},
}), [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 (
<div
key={option.id}
className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
onClick={() => handleSelect(option)}
>
<div className="flex min-w-0 flex-1 items-center">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
{option.icon}
</div>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
)
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
}
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<HTMLElement>) => {
if (event?.defaultPrevented)
return
runSoleOption()
}
if (isValidElement(children)) {
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
const originalOnClick = childElement.props?.onClick
return cloneElement(childElement, {
onClick: (event: MouseEvent<HTMLElement>) => {
if (typeof originalOnClick === 'function')
originalOnClick(event)
if (event?.defaultPrevented)
return
runSoleOption()
},
})
}
return (
<span onClick={handleRunClick}>
<SingleOptionTrigger runSoleOption={runSoleOption}>
{children}
</span>
</SingleOptionTrigger>
)
}

View File

@ -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()
})
})
})

View File

@ -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,
})
})
})

View File

@ -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())

View File

@ -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<typeof createNode>[],
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<typeof import('../../utils')>()),
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,
})
})
})

View File

@ -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<string, unknown> = {}) => ({
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<typeof getContainerSizeChanges>[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<typeof createLayerMap>[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<typeof applyLayoutToNodes>[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<typeof applyLayoutToNodes>[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 })
})
})

View File

@ -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<typeof getNodesConnectedSourceOrTargetHandleIdsMap>[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
})
})

View File

@ -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<EdgeMouseHandler>((_, 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({

View File

@ -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,

View File

@ -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<ToolWithProvider[] | undefined>
}
const findToolInCollections = (
collections: Array<ToolWithProvider[] | undefined>,
data: ToolNodeType,
) => {
const seen = new Set<ToolWithProvider[]>()
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<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
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<ToolWithProvider[] | undefined>
const seen = new Set<ToolWithProvider[]>()
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

View File

@ -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,
}
}

View File

@ -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<string, LayoutResult>)
const containerSizeChanges: Record<string, { width: number, height: number }> = {}
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<number, { minY: number, maxHeight: number }>()
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'

View File

@ -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<string, LayoutResult>,
) => {
return parentNodes.reduce<Record<string, ContainerSizeChange>>((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<string, ContainerSizeChange>,
) => 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<Map<number, LayerInfo>>((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<number, LayerInfo>())
}
const getAlignedYPosition = (
layoutInfo: LayoutResult['nodes'] extends Map<string, infer T> ? T : never,
layerMap: Map<number, LayerInfo>,
) => {
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<string, LayoutResult>
}) => {
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),
}
})
})
})
}

View File

@ -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<string, NonNullable<Awaited<ReturnType<typeof getLayoutForChildNodes>>>>)
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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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]),
}
}

View File

@ -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<typeof payload>(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 })
})
})

View File

@ -0,0 +1,13 @@
type SetInputsMock = (value: unknown) => void
export const createUuidModuleMock = (getId: () => string) => ({
v4: () => getId(),
})
export const createNodeCrudModuleMock = <T>(setInputs: SetInputsMock) => ({
__esModule: true as const,
default: (_id: string, data: T) => ({
inputs: data,
setInputs,
}),
})

View File

@ -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)
})
})

View File

@ -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<AssignerNodeType>(mockSetInputs),
}))
vi.mock('../hooks', () => ({
useGetAvailableVars: () => mockGetAvailableVars,
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('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> = {}): AssignerNodeOperation => ({
variable_selector: ['conversation', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createPayload = (overrides: Partial<AssignerNodeType> = {}): 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)
})
})

View File

@ -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]
})

View File

@ -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<AssignerNodeType>(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,
}
}

View File

@ -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(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
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(
<CurlPanel
nodeId="node-1"
isShow
onHide={vi.fn()}
handleCurlImport={vi.fn()}
/>,
)
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(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
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()
})
})
})

View File

@ -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<HttpNodeType> = {
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<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
const [inputString, setInputString] = useState('')
const { handleNodeSelect } = useNodesInteractions()

View File

@ -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<HttpNodeType> => ({
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<HttpNodeType>, 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<HttpNodeType>, 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<HttpNodeType>, 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<HttpNodeType>, 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<HttpNodeType>, 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<HttpNodeType>,
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 }
}

View File

@ -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(<Variable path="{{#node.field#}}" />)
expect(screen.getByText('{{node/field}}')).toBeInTheDocument()
})
it('should render note values and replace node ids with labels for variable defaults', () => {
const { rerender } = render(
<Note
defaultInput={{
type: 'variable',
selector: ['node-1', 'output'],
value: '',
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
)
expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument()
rerender(
<Note
defaultInput={{
type: 'constant',
value: 'Plain value',
selector: [],
}}
nodeName={nodeId => nodeId}
/>,
)
expect(screen.getByText('Plain value')).toBeInTheDocument()
})
})
})

View File

@ -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<string, string>
children?: MarkdownNode[]
}
type SplitMatchResult = {
tagName: string
properties: Record<string, string>
}
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 (
<span className="text-text-accent">
{
path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
{formatVariablePath(path)}
</span>
)
}
@ -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 (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}

View File

@ -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<typeof createInputs>
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<typeof sortCases>[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<typeof updateCondition>[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())
})
})

View File

@ -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<typeof import('reactflow')>('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<IfElseNodeType>(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> = {}): 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,
}),
}),
]),
}),
]),
}))
})
})

View File

@ -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<string, boolean> = {}
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
})

View File

@ -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<IfElseNodeType>(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<string, boolean> = {}
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<HandleAddCondition>((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<HandleRemoveCondition>((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<HandleUpdateCondition>((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<HandleToggleConditionLogicalOperator>((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<HandleAddSubVariableCondition>((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<HandleUpdateSubVariableCondition>((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<HandleToggleSubVariableConditionLogicalOperator>((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,

View File

@ -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<string, unknown> = {}) => ({
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<typeof getNextChildNodeTypeCount>[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,
}),
}))
})
})

View File

@ -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<typeof import('reactflow')>('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',
})
})
})

View File

@ -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<ContainerBounds>((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,
}
}

View File

@ -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<string, string>) => {
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,

View File

@ -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)
})
})

View File

@ -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<ListFilterNodeType>(mockSetInputs),
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('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> = {}): 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,
}),
}))
})
})

View File

@ -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
}) => (
<input
aria-label="variable-input"
className={className}
value={value}
onChange={e => 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 }) => (
<button onClick={() => onChange(!value)}>{value ? 'true' : 'false'}</button>
),
}))
vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({
default: ({
value,
onSelect,
}: {
value: string
onSelect: (value: string) => void
}) => (
<button onClick={() => onSelect(ComparisonOperator.notEqual)}>
operator:
{value}
</button>
),
}))
vi.mock('../sub-variable-picker', () => ({
default: ({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) => (
<button onClick={() => onChange('size')}>
sub-variable:
{value}
</button>
),
}))
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(
<FilterCondition
condition={{
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['document'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: 'enabled',
comparison_operator: ComparisonOperator.equal,
value: false,
}}
varType={VarType.boolean}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: 'name',
comparison_operator: ComparisonOperator.equal,
value: 'draft',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: '',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.number}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '42' } })
expect(onChange).toHaveBeenLastCalledWith({
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '42',
})
rerender(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.empty,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: 'transfer_method',
comparison_operator: ComparisonOperator.in,
value: ['local_file'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
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(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.in,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument()
})
})

View File

@ -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<typeof Input>
const optionNameI18NPrefix = 'nodes.ifElse.optionName'
const VAR_INPUT_SUPPORTED_KEYS: Record<string, VarType> = {
@ -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<typeof useTranslation>['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<typeof useTranslation>['t']
}) => {
const [isFocus, setIsFocus] = useState(false)
const handleFocusChange = (value: boolean) => {
setIsFocus(value)
onFocusChange(value)
}
if (comparisonOperatorNotRequireValue(comparisonOperator))
return null
if (isSelect) {
return (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => onChange(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
if (isBoolean) {
return (
<BoolValue
value={condition.value as boolean}
onChange={onChange}
/>
)
}
if (supportVariableInput) {
return (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={getConditionValueAsString(condition)}
onChange={onChange}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={handleFocusChange}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
return (
<input
type={getFallbackInputType({ hasSubVariable, condition, varType })}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={getConditionValueAsString(condition)}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
)
}
const FilterCondition: FC<Props> = ({
condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' },
varType,
@ -46,9 +189,8 @@ const FilterCondition: FC<Props> = ({
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<Props> = ({
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<Props> = ({
})
}, [onChange, expectedVarType])
// Extract input rendering logic to avoid nested ternary
let inputElement: React.ReactNode = null
if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) {
if (isSelect) {
inputElement = (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => handleChange('value')(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
else if (isBoolean) {
inputElement = (
<BoolValue
value={condition.value as boolean}
onChange={handleChange('value')}
/>
)
}
else if (supportVariableInput) {
inputElement = (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={
getConditionValueAsString(condition)
}
onChange={handleChange('value')}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={setIsFocus}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
else {
inputElement = (
<input
type={((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) ? 'number' : 'text'}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={
getConditionValueAsString(condition)
}
onChange={e => handleChange('value')(e.target.value)}
readOnly={readOnly}
/>
)
}
}
return (
<div>
{hasSubVariable && (
@ -179,7 +243,23 @@ const FilterCondition: FC<Props> = ({
file={hasSubVariable ? { key: condition.key } : undefined}
disabled={readOnly}
/>
{inputElement}
<ValueInput
comparisonOperator={condition.comparison_operator}
isSelect={isSelect}
isArrayValue={isArrayValue}
isBoolean={isBoolean}
supportVariableInput={supportVariableInput}
selectOptions={selectOptions}
condition={condition}
readOnly={readOnly}
availableVars={availableVars}
availableNodesWithParent={availableNodesWithParent}
onFocusChange={(_value) => {}}
onChange={handleChange('value')}
hasSubVariable={hasSubVariable}
varType={varType}
t={t}
/>
</div>
</div>
)

View File

@ -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
})

View File

@ -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])

View File

@ -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> = {}): 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()
})
})

View File

@ -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<LoopNodeType>(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> = {}): 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,
}),
],
}))
})
})

View File

@ -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<string, unknown> = {}) => ({
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,
}),
}))
})
})

View File

@ -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<typeof import('reactflow')>('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',
})
})
})

View File

@ -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> = {}): 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'],
])
})
})

View File

@ -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> = {}): 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,
}),
})
})
})

View File

@ -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<LoopVariable>,
) => 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,
}
}
})

View File

@ -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<HandleAddCondition>((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<HandleRemoveCondition>((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<HandleUpdateCondition>((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<HandleToggleConditionLogicalOperator>(() => {
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<HandleAddSubVariableCondition>((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<HandleUpdateSubVariableCondition>((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<HandleToggleSubVariableConditionLogicalOperator>((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 {

View File

@ -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<ContainerBounds>((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}`,
}
}

View File

@ -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<string, string>) => {
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
})

View File

@ -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<string, unknown>) => {
const vars: Record<string, unknown> = {}
Object.keys(runInputData).forEach((key) => {
vars[key] = runInputData[key]
})
return vars
}
export const dedupeInputVars = (inputVars: InputVar[]) => {
const seen: Record<string, boolean> = {}
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<string, boolean> = {}
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
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)
}

View File

@ -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<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
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<string, any> = {}
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<string, boolean> = {}
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,

View File

@ -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',
}))
})
})

View File

@ -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<string, unknown>) => 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<WebhookTriggerNodeType>(mockSetInputs),
}))
vi.mock('@/service/apps', () => ({
fetchWebhookUrl: vi.fn(),
}))
const mockedFetchWebhookUrl = vi.mocked(fetchWebhookUrl)
const mockedToastNotify = vi.mocked(Toast.notify)
const createPayload = (overrides: Partial<WebhookTriggerNodeType> = {}): 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()
})
})

View File

@ -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) => (
<button type="button" onClick={() => onChange(`${index}:${row.method || 'empty'}`)}>
custom-render
</button>
),
},
{
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(
<GenericTable
title="Headers"
columns={columns}
data={[]}
emptyRowData={{ name: '', enabled: false }}
onChange={onChange}
/>,
)
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(
<GenericTable
title="Headers"
columns={columns}
data={[
{ name: 'alpha', enabled: false },
{ name: '', enabled: false },
{ name: 'beta', enabled: true },
]}
emptyRowData={{ name: '', enabled: false }}
onChange={vi.fn()}
/>,
)
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(
<GenericTable
title="Headers"
columns={columns}
data={[{ name: 'alpha', enabled: false }]}
emptyRowData={{ name: '', enabled: false }}
onChange={onChange}
showHeader
/>,
)
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 (
<GenericTable
title="Advanced"
columns={advancedColumns}
data={data}
emptyRowData={{ method: '', preview: '' }}
onChange={(nextData) => {
onChange(nextData)
setData(nextData as { method: string, preview: string }[])
}}
/>
)
}
render(
<ControlledTable />,
)
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(
<GenericTable
title="Advanced"
columns={advancedColumns}
data={[{ method: 'post', preview: '' }]}
emptyRowData={{ method: '', preview: '' }}
onChange={onChange}
readonly
/>,
)
await user.click(screen.getByRole('button', { name: 'custom-render' }))
expect(onChange).not.toHaveBeenCalled()
})
it('should show readonly placeholder without rendering editable rows', () => {
render(
<GenericTable
title="Headers"
columns={columns}
data={[]}
emptyRowData={{ name: '', enabled: false }}
onChange={vi.fn()}
readonly
placeholder="No data"
/>,
)
expect(screen.getByText('No data')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})

View File

@ -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<DisplayRow[]>((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 (
<Input
value={(value as string) || ''}
onChange={(e) => {
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 (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => 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 (
<div className="flex h-7 items-center">
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
/>
</div>
)
}
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<GenericTableProps> = ({
title,
columns,
@ -68,42 +188,8 @@ const GenericTable: FC<GenericTableProps> = ({
className,
showHeader = false,
}) => {
// Build the rows to display while keeping a stable mapping to original data
const displayRows = useMemo<DisplayRow[]>(() => {
// 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<GenericTableProps> = ({
}, [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<GenericTableProps> = ({
switch (column.type) {
case 'input':
return (
<Input
value={(value as string) || ''}
onChange={(e) => {
// 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 (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => 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 (
<div className="flex h-7 items-center">
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
/>
</div>
)
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<GenericTableProps> = ({
className="p-1"
aria-label="Delete row"
>
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
</button>
</div>

View File

@ -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
})

View File

@ -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])

View File

@ -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<VariableAssignerNodeType>(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> = {}): 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 }),
}))
})
})

View File

@ -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
})

View File

@ -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<Record<string, string>>({})
const oldNameRef = useRef<Record<string, string>>({})
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,
}
}

View File

@ -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()
})
})
})

View File

@ -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<typeof useLexicalComposerContext>[0],
noteEditorStore: ReturnType<typeof useNoteEditorStore>,
) => {
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<typeof useLexicalComposerContext>[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,

View File

@ -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<string, unknown>
}
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
? (
<div>
<button onClick={onCancel}>Cancel remove</button>
<button onClick={onConfirm}>Confirm remove</button>
</div>
)
: 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
}) => (
<div>
<span>{env.name}</span>
<button onClick={() => onEdit(env)}>
Edit
{' '}
{env.name}
</button>
<button onClick={() => onDelete(env)}>
Delete
{' '}
{env.name}
</button>
</div>
),
}))
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<void>
setOpen: (open: boolean) => void
}) => (
<div>
<span>
Variable trigger:
{open ? 'open' : 'closed'}
:
{env?.name || 'new'}
</span>
<button onClick={() => setOpen(true)}>Open variable modal</button>
<button
onClick={() => onSave(mockVariableTriggerState.savePayload || env || {
id: 'env-created',
name: 'created_name',
value: 'created-value',
value_type: 'string',
description: 'created',
})}
>
Save variable
</button>
<button onClick={onClose}>Close variable modal</button>
</div>
),
}))
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
value: '[__HIDDEN__]',
value_type: 'secret',
description: 'secret description',
...overrides,
})
const renderWithProviders = (
ui: ReactElement,
storeState: Partial<Shape> = {},
) => {
const store = createWorkflowStore({})
store.setState(storeState)
return {
store,
...render(
<WorkflowContext value={store}>
{ui}
</WorkflowContext>,
),
}
}
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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(<EnvPanel />, {
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],
[],
)
})
})

View File

@ -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<typeof useStoreApi>
envSecrets: Record<string, string>
updateEnvList: (envList: EnvironmentVariable[]) => void
setEnvSecrets: (envSecrets: Record<string, string>) => void
doSyncWorkflowDraft: () => Promise<void>
}) => {
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<EnvironmentVariable>()
const [showRemoveVarConfirm, setShowRemoveConfirm] = useState(false)
const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false)
const [cacheForDelete, setCacheForDelete] = useState<EnvironmentVariable>()
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 (
<div
@ -159,6 +196,7 @@ const EnvPanel = () => {
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={() => setShowEnvPanel(false)}
>
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
@ -170,7 +208,7 @@ const EnvPanel = () => {
setOpen={setShowVariableModal}
env={currentVar}
onSave={handleSave}
onClose={() => setCurrentVar(undefined)}
onClose={handleVariableModalClose}
/>
</div>
<div className="grow overflow-y-auto rounded-b-2xl px-4">
@ -185,7 +223,7 @@ const EnvPanel = () => {
</div>
<RemoveEffectVarConfirm
isShow={showRemoveVarConfirm}
onCancel={() => setShowRemoveConfirm(false)}
onCancel={() => setShowRemoveVarConfirm(false)}
onConfirm={() => cacheForDelete && handleDelete(cacheForDelete)}
/>
</div>

View File

@ -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> = {}): 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<NonNullable<NodeTracing['execution_metadata']>> = {}) => ({
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(
<IterationLogTrigger
nodeInfo={createNodeTracing({
details: [missingFailedIteration],
execution_metadata: createExecutionMetadata({
iteration_duration_map: iterationDurationMap,
}),
})}
allExecutions={allExecutions}
onShowIterationResultList={onShowIterationResultList}
/>,
)
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(
<IterationLogTrigger
nodeInfo={createNodeTracing({
details: detailList,
metadata: {
iterator_length: 3,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
})}
onShowIterationResultList={onShowIterationResultList}
/>,
)
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(
<IterationLogTrigger
nodeInfo={createNodeTracing({
execution_metadata: createExecutionMetadata({
iteration_duration_map: iterationDurationMap,
}),
})}
onShowIterationResultList={onShowIterationResultList}
/>,
)
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(
<IterationLogTrigger
nodeInfo={createNodeTracing({
details: [[createNodeTracing({ id: 'detail-success' })]],
execution_metadata: createExecutionMetadata({
iteration_duration_map: iterationDurationMap,
}),
})}
allExecutions={allExecutions}
onShowIterationResultList={onShowIterationResultList}
/>,
)
expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.error/i })).toBeInTheDocument()
await user.click(screen.getByRole('button'))
expect(onShowIterationResultList).toHaveBeenCalledWith([[allExecutions[0]]], iterationDurationMap)
})
})
})

View File

@ -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<number>()
const failedIterationIndices = new Set<number>()
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<number>()
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<HTMLButtonElement>) => {
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<number>()
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<number>()
// 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 (
<Button
className="flex w-full cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-components-button-tertiary-bg-hover px-3 py-2 hover:bg-components-button-tertiary-bg-hover"
onClick={handleOnShowIterationDetail}
>
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
<Iteration className="h-4 w-4 shrink-0 text-components-button-tertiary-text" />
<div className="system-sm-medium flex-1 text-left text-components-button-tertiary-text">
{t('nodes.iteration.iteration', { ns: 'workflow', count: displayIterationCount })}
@ -144,6 +154,7 @@ const IterationLogTrigger = ({
</>
)}
</div>
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
<RiArrowRightSLine className="h-4 w-4 shrink-0 text-components-button-tertiary-text" />
</Button>
)

View File

@ -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
}