Compare commits

...

1 Commits

Author SHA1 Message Date
CodingOnStar
169511e68b test(workflow): refactor low-risk components and add phase 1 coverage 2026-03-23 17:29:06 +08:00
20 changed files with 2298 additions and 783 deletions

View File

@@ -0,0 +1,132 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Tabs from '../tabs'
import { TabsEnum } from '../types'
const {
mockSetState,
mockInvalidateBuiltInTools,
} = vi.hoisted(() => ({
mockSetState: vi.fn(),
mockInvalidateBuiltInTools: vi.fn(),
}))
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: [{ icon: '/tool.svg', name: 'tool' }] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
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 }> }) => (
<div>
tools-content
<span>{props.buildInTools[0]?.icon}</span>
</div>
),
}))
describe('Tabs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
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()
})
it('should sync normalized tools into workflow store state', () => {
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
expect(mockSetState).toHaveBeenCalled()
})
})

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(
'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
// 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,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

@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CurlPanel from '../curl-panel'
import { parseCurl } 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 } = 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(parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
})
})
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',
}))
})
})
})

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,145 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VarType } from '../../../../types'
import { ComparisonOperator } from '../../../if-else/types'
import FilterCondition from '../filter-condition'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: () => ({
availableVars: [],
availableNodesWithParent: [],
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) => (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
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()
})
it('should render a select input for array-backed file conditions', () => {
render(
<FilterCondition
condition={{
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['document'],
}}
varType={VarType.file}
onChange={vi.fn()}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByText(/operator:/)).toBeInTheDocument()
expect(screen.getByText(/sub-variable:/)).toBeInTheDocument()
})
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 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: '',
})
})
})

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,84 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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]',
},
]
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 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 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,155 @@
import { renderHook } from '@testing-library/react'
import { useCommand, useFontSize } from '../hooks'
const {
mockDispatchCommand,
mockEditorUpdate,
mockRegisterUpdateListener,
mockRegisterCommand,
mockRead,
mockSetLinkAnchorElement,
mockSelectionNode,
mockSelection,
mockPatchStyleText,
mockSetSelection,
mockSelectionFontSize,
} = vi.hoisted(() => ({
mockDispatchCommand: vi.fn(),
mockEditorUpdate: vi.fn(),
mockRegisterUpdateListener: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRead: vi.fn(),
mockSetLinkAnchorElement: vi.fn(),
mockSelectionNode: {
getParent: () => 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(),
}))
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: vi.fn(),
}))
vi.mock('@lexical/utils', () => ({
mergeRegister: (...cleanups: Array<() => void>) => () => cleanups.forEach(cleanup => cleanup()),
}))
vi.mock('lexical', () => ({
$createParagraphNode: () => ({ type: 'paragraph' }),
$getSelection: () => mockSelection,
$isRangeSelection: () => true,
$setSelection: mockSetSelection,
COMMAND_PRIORITY_CRITICAL: 4,
FORMAT_TEXT_COMMAND: 'format-text-command',
SELECTION_CHANGE_COMMAND: 'selection-change-command',
}))
vi.mock('../../store', () => ({
useNoteEditorStore: () => ({
getState: () => ({
selectedIsBullet: false,
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')
mockSelection.anchor.getNode.mockReturnValue(mockSelectionNode)
mockSelection.focus.getNode.mockReturnValue(mockSelectionNode)
})
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)
})
})
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')
})
})
})

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,182 @@
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 } 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'
const {
mockDoSyncWorkflowDraft,
mockGetNodes,
mockSetNodes,
} = vi.hoisted(() => ({
mockDoSyncWorkflowDraft: vi.fn(() => Promise.resolve()),
mockGetNodes: vi.fn(() => []),
mockSetNodes: vi.fn(),
}))
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/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: ({
env,
onClose,
onSave,
}: {
env?: EnvironmentVariable
onClose: () => void
onSave: (env: EnvironmentVariable) => Promise<void>
}) => (
<div>
<button
onClick={() => onSave(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([])
})
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)
})
})

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

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