From 169511e68b190bec98814f7705ebe81ba6a7d8b9 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 23 Mar 2026 17:29:06 +0800 Subject: [PATCH] test(workflow): refactor low-risk components and add phase 1 coverage --- .../block-selector/__tests__/tabs.spec.tsx | 132 ++++++++++ .../workflow/block-selector/tabs.tsx | 229 +++++++++------- .../header/__tests__/test-run-menu.spec.tsx | 125 +++++++++ .../workflow/header/test-run-menu-helpers.tsx | 118 +++++++++ .../workflow/header/test-run-menu.tsx | 137 +++------- .../components/__tests__/curl-panel.spec.tsx | 95 +++++++ .../nodes/http/components/curl-panel.tsx | 100 +------ .../nodes/http/components/curl-parser.ts | 171 ++++++++++++ .../__tests__/variable-in-markdown.spec.tsx | 114 ++++++++ .../components/variable-in-markdown.tsx | 212 +++++++-------- .../__tests__/filter-condition.spec.tsx | 145 +++++++++++ .../components/filter-condition.tsx | 244 ++++++++++++------ .../__tests__/generic-table.spec.tsx | 84 ++++++ .../components/generic-table.tsx | 220 +++++++++------- .../toolbar/__tests__/hooks.spec.tsx | 155 +++++++++++ .../note-node/note-editor/toolbar/hooks.ts | 114 ++++---- .../panel/env-panel/__tests__/index.spec.tsx | 182 +++++++++++++ .../workflow/panel/env-panel/index.tsx | 200 ++++++++------ .../__tests__/iteration-log-trigger.spec.tsx | 133 ++++++++++ .../iteration-log/iteration-log-trigger.tsx | 171 ++++++------ 20 files changed, 2298 insertions(+), 783 deletions(-) create mode 100644 web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx create mode 100644 web/app/components/workflow/header/test-run-menu-helpers.tsx create mode 100644 web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx create mode 100644 web/app/components/workflow/nodes/http/components/curl-parser.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx create mode 100644 web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx create mode 100644 web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx create mode 100644 web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx new file mode 100644 index 0000000000..6056c7379b --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx @@ -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 + }) => ( +
+ {popupContent} + {children} +
+ ), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({ + systemFeatures: { enable_marketplace: true }, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFeaturedToolsRecommendations: () => ({ + plugins: [], + isLoading: false, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: [{ 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: () =>
start-content
, +})) + +vi.mock('../blocks', () => ({ + default: () =>
blocks-content
, +})) + +vi.mock('../data-sources', () => ({ + default: () =>
sources-content
, +})) + +vi.mock('../all-tools', () => ({ + default: (props: { buildInTools: Array<{ icon: string }> }) => ( +
+ tools-content + {props.buildInTools[0]?.icon} +
+ ), +})) + +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:
filter
, + } + + it('should render start content and disabled tab tooltip text', () => { + render() + + expect(screen.getByText('start-content')).toBeInTheDocument() + expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument() + }) + + it('should switch tabs through click handlers and render tools content with normalized icons', () => { + const onActiveTabChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('Start')) + + expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start) + expect(screen.getByText('tools-content')).toBeInTheDocument() + expect(screen.getByText('/console/tool.svg')).toBeInTheDocument() + }) + + it('should sync normalized tools into workflow store state', () => { + render() + + expect(mockSetState).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index f1eeba7435..51620489d7 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -41,6 +41,122 @@ export type TabsProps = { forceShowStartContent?: boolean // Force show Start content even when noBlocks=true allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet). } + +const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => { + if (!list || !currentBasePath) + return list + + let changed = false + const normalized = list.map((provider) => { + if (typeof provider.icon !== 'string') + return provider + + const shouldPrefix = provider.icon.startsWith('/') + && !provider.icon.startsWith(`${currentBasePath}/`) + + if (!shouldPrefix) + return provider + + changed = true + return { + ...provider, + icon: `${currentBasePath}${provider.icon}`, + } + }) + + return changed ? normalized : list +} + +const getStoreToolUpdates = ({ + state, + buildInTools, + customTools, + workflowTools, + mcpTools, +}: { + state: { + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] + } + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] +}) => { + const updates: Partial = {} + + if (buildInTools !== undefined && state.buildInTools !== buildInTools) + updates.buildInTools = buildInTools + if (customTools !== undefined && state.customTools !== customTools) + updates.customTools = customTools + if (workflowTools !== undefined && state.workflowTools !== workflowTools) + updates.workflowTools = workflowTools + if (mcpTools !== undefined && state.mcpTools !== mcpTools) + updates.mcpTools = mcpTools + + return updates +} + +const TabHeaderItem = ({ + tab, + activeTab, + onActiveTabChange, + disabledTip, +}: { + tab: TabsProps['tabs'][number] + activeTab: TabsEnum + onActiveTabChange: (activeTab: TabsEnum) => void + disabledTip: string +}) => { + const className = cn( + '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 ( + +
+ {tab.name} +
+
+ ) + } + + return ( +
+ {tab.name} +
+ ) +} + const Tabs: FC = ({ activeTab, onActiveTabChange, @@ -71,51 +187,21 @@ const Tabs: FC = ({ plugins: featuredPlugins = [], isLoading: isFeaturedLoading, } = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) - - const normalizeToolList = useMemo(() => { - return (list?: ToolWithProvider[]) => { - if (!list) - return list - if (!basePath) - return list - let changed = false - const normalized = list.map((provider) => { - if (typeof provider.icon === 'string') { - const icon = provider.icon - const shouldPrefix = Boolean(basePath) - && icon.startsWith('/') - && !icon.startsWith(`${basePath}/`) - - if (shouldPrefix) { - changed = true - return { - ...provider, - icon: `${basePath}${icon}`, - } - } - } - return provider - }) - return changed ? normalized : list - } - }, [basePath]) + const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools]) + const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools]) + const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools]) + const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools]) + const disabledTip = t('tabs.startDisabledTip', { ns: 'workflow' }) useEffect(() => { workflowStore.setState((state) => { - const updates: Partial = {} - const normalizedBuiltIn = normalizeToolList(buildInTools) - const normalizedCustom = normalizeToolList(customTools) - const normalizedWorkflow = normalizeToolList(workflowTools) - const normalizedMCP = normalizeToolList(mcpTools) - - if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn) - updates.buildInTools = normalizedBuiltIn - if (normalizedCustom !== undefined && state.customTools !== normalizedCustom) - updates.customTools = normalizedCustom - if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow) - updates.workflowTools = normalizedWorkflow - if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP) - updates.mcpTools = normalizedMCP + const updates = getStoreToolUpdates({ + state, + buildInTools: normalizedBuiltInTools, + customTools: normalizedCustomTools, + workflowTools: normalizedWorkflowTools, + mcpTools: normalizedMcpTools, + }) if (!Object.keys(updates).length) return state return { @@ -123,7 +209,7 @@ const Tabs: FC = ({ ...updates, } }) - }, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools]) + }, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore]) return (
e.stopPropagation()}> @@ -131,46 +217,15 @@ const Tabs: FC = ({ !noBlocks && (
{ - tabs.map((tab) => { - const commonProps = { - 'className': cn( - 'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3', - tab.disabled - ? 'cursor-not-allowed text-text-disabled opacity-60' - : activeTab === tab.key - ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' - : 'cursor-pointer text-text-tertiary', - ), - 'aria-disabled': tab.disabled, - 'onClick': () => { - if (tab.disabled || activeTab === tab.key) - return - onActiveTabChange(tab.key) - }, - } as const - if (tab.disabled) { - return ( - -
- {tab.name} -
-
- ) - } - return ( -
- {tab.name} -
- ) - }) + tabs.map(tab => ( + + )) }
) @@ -219,10 +274,10 @@ const Tabs: FC = ({ onSelect={onSelect} tags={tags} canNotSelectMultiple - buildInTools={buildInTools || []} - customTools={customTools || []} - workflowTools={workflowTools || []} - mcpTools={mcpTools || []} + buildInTools={normalizedBuiltInTools || []} + customTools={normalizedCustomTools || []} + workflowTools={normalizedWorkflowTools || []} + mcpTools={normalizedMcpTools || []} onTagsChange={onTagsChange} isInRAGPipeline={inRAGPipeline} featuredPlugins={featuredPlugins} diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx new file mode 100644 index 0000000000..2e3384b61e --- /dev/null +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -0,0 +1,125 @@ +import type { TestRunMenuRef, TriggerOption } from '../test-run-menu' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import * as React from 'react' +import TestRunMenu, { TriggerType } from '../test-run-menu' + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) =>
{children}
, + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
, +})) + +vi.mock('../shortcuts-name', () => ({ + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +const createOption = (overrides: Partial = {}): TriggerOption => ({ + id: 'user-input', + type: TriggerType.UserInput, + name: 'User Input', + icon: icon, + enabled: true, + ...overrides, +}) + +describe('TestRunMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should run the only enabled option directly and preserve the child click handler', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const originalOnClick = vi.fn() + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Run now' })) + + expect(originalOnClick).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-input' })) + }) + + it('should expose toggle via ref and select a shortcut when multiple options are available', () => { + const onSelect = vi.fn() + + const Harness = () => { + const ref = React.useRef(null) + + return ( + <> + + + + + + ) + } + + render() + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' })) + }) + fireEvent.keyDown(window, { key: '0' }) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' })) + expect(screen.getByText('~')).toBeInTheDocument() + }) + + it('should ignore disabled options in the rendered menu', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Open menu' })) + + expect(screen.queryByText('User Input')).not.toBeInTheDocument() + expect(screen.getByText('Webhook Trigger')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx new file mode 100644 index 0000000000..dbe6b616a0 --- /dev/null +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -0,0 +1,118 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' +import type { TriggerOption } from './test-run-menu' +import { + cloneElement, + isValidElement, + useEffect, +} from 'react' +import ShortcutsName from '../shortcuts-name' + +export type ShortcutMapping = { + option: TriggerOption + shortcutKey: string +} + +export const getNormalizedShortcutKey = (event: KeyboardEvent) => { + return event.key === '`' ? '~' : event.key +} + +export const OptionRow = ({ + option, + shortcutKey, + onSelect, +}: { + option: TriggerOption + shortcutKey?: string + onSelect: (option: TriggerOption) => void +}) => { + return ( +
onSelect(option)} + > +
+
+ {option.icon} +
+ {option.name} +
+ {shortcutKey && ( + + )} +
+ ) +} + +export const useShortcutMenu = ({ + open, + shortcutMappings, + handleSelect, +}: { + open: boolean + shortcutMappings: ShortcutMapping[] + handleSelect: (option: TriggerOption) => void +}) => { + useEffect(() => { + if (!open) + return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) + return + + const normalizedKey = getNormalizedShortcutKey(event) + const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) + + if (mapping) { + event.preventDefault() + handleSelect(mapping.option) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleSelect, open, shortcutMappings]) +} + +export const SingleOptionTrigger = ({ + children, + runSoleOption, +}: { + children: React.ReactNode + runSoleOption: () => void +}) => { + const handleRunClick = (event?: MouseEvent) => { + if (event?.defaultPrevented) + return + + runSoleOption() + } + + if (isValidElement(children)) { + const childElement = children as ReactElement<{ onClick?: MouseEventHandler }> + const originalOnClick = childElement.props?.onClick + + // eslint-disable-next-line react/no-clone-element + return cloneElement(childElement, { + onClick: (event: MouseEvent) => { + if (typeof originalOnClick === 'function') + originalOnClick(event) + + if (event?.defaultPrevented) + return + + runSoleOption() + }, + }) + } + + return ( + + {children} + + ) +} diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index 2cda0501e8..8cffd5417b 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -1,22 +1,8 @@ -import type { MouseEvent, MouseEventHandler, ReactElement } from 'react' -import { - cloneElement, - forwardRef, - isValidElement, - - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react' +import type { ShortcutMapping } from './test-run-menu-helpers' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -import ShortcutsName from '../shortcuts-name' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers' export enum TriggerType { UserInput = 'user_input', @@ -52,9 +38,24 @@ export type TestRunMenuRef = { toggle: () => void } -type ShortcutMapping = { - option: TriggerOption - shortcutKey: string +const getEnabledOptions = (options: TestRunOptions) => { + const flattened: TriggerOption[] = [] + + if (options.userInput) + flattened.push(options.userInput) + if (options.runAll) + flattened.push(options.runAll) + flattened.push(...options.triggers) + + return flattened.filter(option => option.enabled !== false) +} + +const getMenuVisibility = (options: TestRunOptions) => { + return { + hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput), + hasTriggers: options.triggers.some(trigger => trigger.enabled !== false), + hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll), + } } const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { @@ -76,6 +77,7 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { return mappings } +// eslint-disable-next-line react/no-forward-ref const TestRunMenu = forwardRef(({ options, onSelect, @@ -97,17 +99,7 @@ const TestRunMenu = forwardRef(({ setOpen(false) }, [onSelect]) - const enabledOptions = useMemo(() => { - const flattened: TriggerOption[] = [] - - if (options.userInput) - flattened.push(options.userInput) - if (options.runAll) - flattened.push(options.runAll) - flattened.push(...options.triggers) - - return flattened.filter(option => option.enabled !== false) - }, [options]) + const enabledOptions = useMemo(() => getEnabledOptions(options), [options]) const hasSingleEnabledOption = enabledOptions.length === 1 const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined @@ -117,6 +109,12 @@ const TestRunMenu = forwardRef(({ handleSelect(soleEnabledOption) }, [handleSelect, soleEnabledOption]) + useShortcutMenu({ + open, + shortcutMappings, + handleSelect, + }) + useImperativeHandle(ref, () => ({ toggle: () => { if (hasSingleEnabledOption) { @@ -128,84 +126,17 @@ const TestRunMenu = forwardRef(({ }, }), [hasSingleEnabledOption, runSoleOption]) - useEffect(() => { - if (!open) - return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) - return - - const normalizedKey = event.key === '`' ? '~' : event.key - const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) - - if (mapping) { - event.preventDefault() - handleSelect(mapping.option) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => { - window.removeEventListener('keydown', handleKeyDown) - } - }, [handleSelect, open, shortcutMappings]) - const renderOption = (option: TriggerOption) => { - const shortcutKey = shortcutKeyById.get(option.id) - - return ( -
handleSelect(option)} - > -
-
- {option.icon} -
- {option.name} -
- {shortcutKey && ( - - )} -
- ) + return } - const hasUserInput = !!options.userInput && options.userInput.enabled !== false - const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false) - const hasRunAll = !!options.runAll && options.runAll.enabled !== false + const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options]) if (hasSingleEnabledOption && soleEnabledOption) { - const handleRunClick = (event?: MouseEvent) => { - if (event?.defaultPrevented) - return - - runSoleOption() - } - - if (isValidElement(children)) { - const childElement = children as ReactElement<{ onClick?: MouseEventHandler }> - const originalOnClick = childElement.props?.onClick - - return cloneElement(childElement, { - onClick: (event: MouseEvent) => { - if (typeof originalOnClick === 'function') - originalOnClick(event) - - if (event?.defaultPrevented) - return - - runSoleOption() - }, - }) - } - return ( - + {children} - + ) } diff --git a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx new file mode 100644 index 0000000000..4e52274fb1 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx @@ -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( + , + ) + + await user.type(screen.getByRole('textbox'), 'curl https://example.com') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onHide).toHaveBeenCalledTimes(1) + expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({ + method: 'get', + url: 'https://example.com', + })) + expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true) + }) + + it('should notify the user when the curl command is invalid', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.type(screen.getByRole('textbox'), 'invalid') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index 6c809c310f..7b6a26cc29 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal' import Textarea from '@/app/components/base/textarea' import Toast from '@/app/components/base/toast' import { useNodesInteractions } from '@/app/components/workflow/hooks' -import { BodyPayloadValueType, BodyType, Method } from '../types' +import { parseCurl } from './curl-parser' type Props = { nodeId: string @@ -18,104 +18,6 @@ type Props = { handleCurlImport: (node: HttpNodeType) => void } -const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => { - if (!curlCommand.trim().toLowerCase().startsWith('curl')) - return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } - - const node: Partial = { - title: 'HTTP Request', - desc: 'Imported from cURL', - method: undefined, - url: '', - headers: '', - params: '', - body: { type: BodyType.none, data: '' }, - } - const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || [] - let hasData = false - - for (let i = 1; i < args.length; i++) { - const arg = args[i].replace(/^['"]|['"]$/g, '') - switch (arg) { - case '-X': - case '--request': - if (i + 1 >= args.length) - return { node: null, error: 'Missing HTTP method after -X or --request.' } - node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get - hasData = true - break - case '-H': - case '--header': - if (i + 1 >= args.length) - return { node: null, error: 'Missing header value after -H or --header.' } - node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '') - break - case '-d': - case '--data': - case '--data-raw': - case '--data-binary': { - if (i + 1 >= args.length) - return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' } - const bodyPayload = [{ - type: BodyPayloadValueType.text, - value: args[++i].replace(/^['"]|['"]$/g, ''), - }] - node.body = { type: BodyType.rawText, data: bodyPayload } - break - } - case '-F': - case '--form': { - if (i + 1 >= args.length) - return { node: null, error: 'Missing form data after -F or --form.' } - if (node.body?.type !== BodyType.formData) - node.body = { type: BodyType.formData, data: '' } - const formData = args[++i].replace(/^['"]|['"]$/g, '') - const [key, ...valueParts] = formData.split('=') - if (!key) - return { node: null, error: 'Invalid form data format.' } - let value = valueParts.join('=') - - // To support command like `curl -F "file=@/path/to/file;type=application/zip"` - // the `;type=application/zip` should translate to `Content-Type: application/zip` - const typeRegex = /^(.+?);type=(.+)$/ - const typeMatch = typeRegex.exec(value) - if (typeMatch) { - const [, actualValue, mimeType] = typeMatch - value = actualValue - node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}` - } - - node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}` - break - } - case '--json': - if (i + 1 >= args.length) - return { node: null, error: 'Missing JSON data after --json.' } - node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') } - break - default: - if (arg.startsWith('http') && !node.url) - node.url = arg - break - } - } - - // Determine final method - node.method = node.method || (hasData ? Method.post : Method.get) - - if (!node.url) - return { node: null, error: 'Missing URL or url not start with http.' } - - // Extract query params from URL - const urlParts = node.url?.split('?') || [] - if (urlParts.length > 1) { - node.url = urlParts[0] - node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ') - } - - return { node: node as HttpNodeType, error: null } -} - const CurlPanel: FC = ({ nodeId, isShow, onHide, handleCurlImport }) => { const [inputString, setInputString] = useState('') const { handleNodeSelect } = useNodesInteractions() diff --git a/web/app/components/workflow/nodes/http/components/curl-parser.ts b/web/app/components/workflow/nodes/http/components/curl-parser.ts new file mode 100644 index 0000000000..ba9319cbf0 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/curl-parser.ts @@ -0,0 +1,171 @@ +import type { HttpNodeType } from '../types' +import { BodyPayloadValueType, BodyType, Method } from '../types' + +const METHOD_ARG_FLAGS = new Set(['-X', '--request']) +const HEADER_ARG_FLAGS = new Set(['-H', '--header']) +const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary']) +const FORM_ARG_FLAGS = new Set(['-F', '--form']) + +type ParseStepResult = { + error: string | null + nextIndex: number + hasData?: boolean +} + +const stripWrappedQuotes = (value: string) => { + return value.replace(/^['"]|['"]$/g, '') +} + +const parseCurlArgs = (curlCommand: string) => { + return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || [] +} + +const buildDefaultNode = (): Partial => ({ + title: 'HTTP Request', + desc: 'Imported from cURL', + method: undefined, + url: '', + headers: '', + params: '', + body: { type: BodyType.none, data: '' }, +}) + +const extractUrlParams = (url: string) => { + const urlParts = url.split('?') + if (urlParts.length <= 1) + return { url, params: '' } + + return { + url: urlParts[0], + params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '), + } +} + +const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => { + if (index + 1 >= args.length) + return { value: null, error } + + return { + value: stripWrappedQuotes(args[index + 1]), + error: null, + } +} + +const applyMethodArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index, hasData: false } + + node.method = (nextArg.value.toLowerCase() as Method) || Method.get + return { error: null, nextIndex: index + 1, hasData: true } +} + +const applyHeaderArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.headers += `${node.headers ? '\n' : ''}${nextArg.value}` + return { error: null, nextIndex: index + 1 } +} + +const applyDataArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.body = { + type: BodyType.rawText, + data: [{ type: BodyPayloadValueType.text, value: nextArg.value }], + } + return { error: null, nextIndex: index + 1 } +} + +const applyFormArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + if (node.body?.type !== BodyType.formData) + node.body = { type: BodyType.formData, data: '' } + + const [key, ...valueParts] = nextArg.value.split('=') + if (!key) + return { error: 'Invalid form data format.', nextIndex: index } + + let value = valueParts.join('=') + const typeMatch = /^(.+?);type=(.+)$/.exec(value) + if (typeMatch) { + const [, actualValue, mimeType] = typeMatch + value = actualValue + node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}` + } + + node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}` + return { error: null, nextIndex: index + 1 } +} + +const applyJsonArg = (node: Partial, args: string[], index: number): ParseStepResult => { + const nextArg = getNextArg(args, index, 'Missing JSON data after --json.') + if (nextArg.error || nextArg.value === null) + return { error: nextArg.error, nextIndex: index } + + node.body = { type: BodyType.json, data: nextArg.value } + return { error: null, nextIndex: index + 1 } +} + +const handleCurlArg = ( + arg: string, + node: Partial, + args: string[], + index: number, +): ParseStepResult => { + if (METHOD_ARG_FLAGS.has(arg)) + return applyMethodArg(node, args, index) + + if (HEADER_ARG_FLAGS.has(arg)) + return applyHeaderArg(node, args, index) + + if (DATA_ARG_FLAGS.has(arg)) + return applyDataArg(node, args, index) + + if (FORM_ARG_FLAGS.has(arg)) + return applyFormArg(node, args, index) + + if (arg === '--json') + return applyJsonArg(node, args, index) + + if (arg.startsWith('http') && !node.url) + node.url = arg + + return { error: null, nextIndex: index, hasData: false } +} + +export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => { + if (!curlCommand.trim().toLowerCase().startsWith('curl')) + return { node: null, error: 'Invalid cURL command. Command must start with "curl".' } + + const node = buildDefaultNode() + const args = parseCurlArgs(curlCommand) + let hasData = false + + for (let i = 1; i < args.length; i++) { + const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i) + if (result.error) + return { node: null, error: result.error } + + hasData ||= Boolean(result.hasData) + i = result.nextIndex + } + + node.method = node.method || (hasData ? Method.post : Method.get) + + if (!node.url) + return { node: null, error: 'Missing URL or url not start with http.' } + + const parsedUrl = extractUrlParams(node.url) + node.url = parsedUrl.url + node.params = parsedUrl.params + + return { node: node as HttpNodeType, error: null } +} diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx new file mode 100644 index 0000000000..1847ceaa9b --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/variable-in-markdown.spec.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react' +import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown' + +describe('variable-in-markdown', () => { + describe('rehypeVariable', () => { + it('should replace variable tokens with variable elements and preserve surrounding text', () => { + const tree = { + children: [ + { + type: 'text', + value: 'Hello {{#node.field#}} world', + }, + ], + } + + rehypeVariable()(tree) + + expect(tree.children).toEqual([ + { type: 'text', value: 'Hello ' }, + { + type: 'element', + tagName: 'variable', + properties: { dataPath: '{{#node.field#}}' }, + children: [], + }, + { type: 'text', value: ' world' }, + ]) + }) + + it('should ignore note tokens while processing variable nodes', () => { + const tree = { + children: [ + { + type: 'text', + value: 'Hello {{#$node.field#}} world', + }, + ], + } + + rehypeVariable()(tree) + + expect(tree.children).toEqual([ + { + type: 'text', + value: 'Hello {{#$node.field#}} world', + }, + ]) + }) + }) + + describe('rehypeNotes', () => { + it('should replace note tokens with section nodes and update the parent tag name', () => { + const tree = { + tagName: 'p', + children: [ + { + type: 'text', + value: 'See {{#$node.title#}} please', + }, + ], + } + + rehypeNotes()(tree) + + expect(tree.tagName).toBe('div') + expect(tree.children).toEqual([ + { type: 'text', value: 'See ' }, + { + type: 'element', + tagName: 'section', + properties: { dataName: 'title' }, + children: [], + }, + { type: 'text', value: ' please' }, + ]) + }) + }) + + describe('rendering', () => { + it('should format variable paths for display', () => { + render() + + expect(screen.getByText('{{node/field}}')).toBeInTheDocument() + }) + + it('should render note values and replace node ids with labels for variable defaults', () => { + const { rerender } = render( + nodeId === 'node-1' ? 'Start Node' : nodeId} + />, + ) + + expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument() + + rerender( + nodeId} + />, + ) + + expect(screen.getByText('Plain value')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx index 0da56e3233..a09dc287b4 100644 --- a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx @@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types' const variableRegex = /\{\{#(.+?)#\}\}/g const noteRegex = /\{\{#\$(.+?)#\}\}/g -export function rehypeVariable() { - return (tree: any) => { - const iterate = (node: any, index: number, parent: any) => { - const value = node.value +type MarkdownNode = { + type?: string + value?: string + tagName?: string + properties?: Record + children?: MarkdownNode[] +} +type SplitMatchResult = { + tagName: string + properties: Record +} + +const splitTextNode = ( + value: string, + regex: RegExp, + createMatchNode: (match: RegExpExecArray) => SplitMatchResult, +) => { + const parts: MarkdownNode[] = [] + let lastIndex = 0 + let match = regex.exec(value) + + while (match !== null) { + if (match.index > lastIndex) + parts.push({ type: 'text', value: value.slice(lastIndex, match.index) }) + + const { tagName, properties } = createMatchNode(match) + parts.push({ + type: 'element', + tagName, + properties, + children: [], + }) + + lastIndex = match.index + match[0].length + match = regex.exec(value) + } + + if (!parts.length) + return parts + + if (lastIndex < value.length) + parts.push({ type: 'text', value: value.slice(lastIndex) }) + + return parts +} + +const visitTextNodes = ( + node: MarkdownNode, + transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null, +) => { + if (!node.children) + return + + let index = 0 + while (index < node.children.length) { + const child = node.children[index] + if (child.type === 'text' && typeof child.value === 'string') { + const nextNodes = transform(child.value, node) + if (nextNodes) { + node.children.splice(index, 1, ...nextNodes) + index += nextNodes.length + continue + } + } + + visitTextNodes(child, transform) + index++ + } +} + +const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => { + return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => { + return `#${nodeName(nodeId)}${separator}` + }) +} + +const formatVariablePath = (path: string) => { + return path.replaceAll('.', '/') + .replace('{{#', '{{') + .replace('#}}', '}}') +} + +export function rehypeVariable() { + return (tree: MarkdownNode) => { + visitTextNodes(tree, (value) => { variableRegex.lastIndex = 0 noteRegex.lastIndex = 0 - if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) { - let m: RegExpExecArray | null - let last = 0 - const parts: any[] = [] - variableRegex.lastIndex = 0 - m = variableRegex.exec(value) - while (m !== null) { - if (m.index > last) - parts.push({ type: 'text', value: value.slice(last, m.index) }) + if (!variableRegex.test(value) || noteRegex.test(value)) + return null - parts.push({ - type: 'element', - tagName: 'variable', - properties: { dataPath: m[0].trim() }, - children: [], - }) - - last = m.index + m[0].length - m = variableRegex.exec(value) - } - - if (parts.length) { - if (last < value.length) - parts.push({ type: 'text', value: value.slice(last) }) - - parent.children.splice(index, 1, ...parts) - } - } - if (node.children) { - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < node.children.length) { - iterate(node.children[i], i, node) - i++ - } - } - } - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < tree.children.length) { - iterate(tree.children[i], i, tree) - i++ - } + variableRegex.lastIndex = 0 + return splitTextNode(value, variableRegex, match => ({ + tagName: 'variable', + properties: { dataPath: match[0].trim() }, + })) + }) } } export function rehypeNotes() { - return (tree: any) => { - const iterate = (node: any, index: number, parent: any) => { - const value = node.value + return (tree: MarkdownNode) => { + visitTextNodes(tree, (value, parent) => { + noteRegex.lastIndex = 0 + if (!noteRegex.test(value)) + return null noteRegex.lastIndex = 0 - if (node.type === 'text' && noteRegex.test(value)) { - let m: RegExpExecArray | null - let last = 0 - const parts: any[] = [] - noteRegex.lastIndex = 0 - m = noteRegex.exec(value) - while (m !== null) { - if (m.index > last) - parts.push({ type: 'text', value: value.slice(last, m.index) }) - - const name = m[0].split('.').slice(-1)[0].replace('#}}', '') - parts.push({ - type: 'element', - tagName: 'section', - properties: { dataName: name }, - children: [], - }) - - last = m.index + m[0].length - m = noteRegex.exec(value) + parent.tagName = 'div' + return splitTextNode(value, noteRegex, (match) => { + const name = match[0].split('.').slice(-1)[0].replace('#}}', '') + return { + tagName: 'section', + properties: { dataName: name }, } - - if (parts.length) { - if (last < value.length) - parts.push({ type: 'text', value: value.slice(last) }) - - parent.children.splice(index, 1, ...parts) - parent.tagName = 'div' // h2 can not in p. In note content include the h2 - } - } - if (node.children) { - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < node.children.length) { - iterate(node.children[i], i, node) - i++ - } - } - } - let i = 0 - // Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts) - while (i < tree.children.length) { - iterate(tree.children[i], i, tree) - i++ - } + }) + }) } } export const Variable: React.FC<{ path: string }> = ({ path }) => { return ( - { - path.replaceAll('.', '/') - .replace('{{#', '{{') - .replace('#}}', '}}') - } + {formatVariablePath(path)} ) } @@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => { export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => { const isVariable = defaultInput.type === 'variable' const path = `{{#${defaultInput.selector.join('.')}#}}` - let newPath = path - if (path) { - newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => { - return `#${nodeName(nodeId)}${sep}` - }) - } + const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path return (
{isVariable ? : {defaultInput.value}} diff --git a/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx new file mode 100644 index 0000000000..155b155286 --- /dev/null +++ b/web/app/components/workflow/nodes/list-operator/components/__tests__/filter-condition.spec.tsx @@ -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 + }) => ( + onChange(e.target.value)} + /> + ), +})) + +vi.mock('../../../../panel/chat-variable-panel/components/bool-value', () => ({ + default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({ + default: ({ + value, + onSelect, + }: { + value: string + onSelect: (value: string) => void + }) => ( + + ), +})) + +vi.mock('../sub-variable-picker', () => ({ + default: ({ + value, + onChange, + }: { + value: string + onChange: (value: string) => void + }) => ( + + ), +})) + +describe('FilterCondition', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a select input for array-backed file conditions', () => { + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + await user.click(screen.getByRole('button', { name: 'sub-variable:' })) + + expect(onChange).toHaveBeenCalledWith({ + key: 'size', + comparison_operator: ComparisonOperator.largerThan, + value: '', + }) + }) +}) diff --git a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx index e54bb6ee10..f03b0fdc28 100644 --- a/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx +++ b/web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx @@ -17,6 +17,8 @@ import { ComparisonOperator } from '../../if-else/types' import { comparisonOperatorNotRequireValue, getOperators } from '../../if-else/utils' import SubVariablePicker from './sub-variable-picker' +type VariableInputProps = React.ComponentProps + const optionNameI18NPrefix = 'nodes.ifElse.optionName' const VAR_INPUT_SUPPORTED_KEYS: Record = { @@ -37,6 +39,147 @@ type Props = { nodeId: string } +const getExpectedVarType = (condition: Condition, varType: VarType) => { + return condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType +} + +const getSelectOptions = ( + condition: Condition, + isSelect: boolean, + t: ReturnType['t'], +) => { + if (!isSelect) + return [] + + if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { + return FILE_TYPE_OPTIONS.map(item => ({ + name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), + value: item.value, + })) + } + + if (condition.key === 'transfer_method') { + return TRANSFER_METHOD.map(item => ({ + name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), + value: item.value, + })) + } + + return [] +} + +const getFallbackInputType = ({ + hasSubVariable, + condition, + varType, +}: { + hasSubVariable: boolean + condition: Condition + varType: VarType +}) => { + return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) + ? 'number' + : 'text' +} + +const ValueInput = ({ + comparisonOperator, + isSelect, + isArrayValue, + isBoolean, + supportVariableInput, + selectOptions, + condition, + readOnly, + availableVars, + availableNodesWithParent, + onFocusChange, + onChange, + hasSubVariable, + varType, + t, +}: { + comparisonOperator: ComparisonOperator + isSelect: boolean + isArrayValue: boolean + isBoolean: boolean + supportVariableInput: boolean + selectOptions: Array<{ name: string, value: string }> + condition: Condition + readOnly: boolean + availableVars: VariableInputProps['nodesOutputVars'] + availableNodesWithParent: VariableInputProps['availableNodes'] + onFocusChange: (value: boolean) => void + onChange: (value: unknown) => void + hasSubVariable: boolean + varType: VarType + t: ReturnType['t'] +}) => { + const [isFocus, setIsFocus] = useState(false) + + const handleFocusChange = (value: boolean) => { + setIsFocus(value) + onFocusChange(value) + } + + if (comparisonOperatorNotRequireValue(comparisonOperator)) + return null + + if (isSelect) { + return ( + + ) + } + + return ( + onChange(e.target.value)} + readOnly={readOnly} + /> + ) +} + const FilterCondition: FC = ({ condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' }, varType, @@ -46,9 +189,8 @@ const FilterCondition: FC = ({ nodeId, }) => { const { t } = useTranslation() - const [isFocus, setIsFocus] = useState(false) - const expectedVarType = condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType + const expectedVarType = getExpectedVarType(condition, varType) const supportVariableInput = !!expectedVarType const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { @@ -62,24 +204,7 @@ const FilterCondition: FC = ({ const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type' const isBoolean = varType === VarType.boolean - const selectOptions = useMemo(() => { - if (isSelect) { - if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) { - return FILE_TYPE_OPTIONS.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), - value: item.value, - })) - } - if (condition.key === 'transfer_method') { - return TRANSFER_METHOD.map(item => ({ - name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }), - value: item.value, - })) - } - return [] - } - return [] - }, [condition.comparison_operator, condition.key, isSelect, t]) + const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t]) const handleChange = useCallback((key: string) => { return (value: any) => { @@ -100,67 +225,6 @@ const FilterCondition: FC = ({ }) }, [onChange, expectedVarType]) - // Extract input rendering logic to avoid nested ternary - let inputElement: React.ReactNode = null - if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) { - if (isSelect) { - inputElement = ( - - ) - } - else { - inputElement = ( - handleChange('value')(e.target.value)} - readOnly={readOnly} - /> - ) - } - } - return (
{hasSubVariable && ( @@ -179,7 +243,23 @@ const FilterCondition: FC = ({ file={hasSubVariable ? { key: condition.key } : undefined} disabled={readOnly} /> - {inputElement} + {}} + onChange={handleChange('value')} + hasSubVariable={hasSubVariable} + varType={varType} + t={t} + />
) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx new file mode 100644 index 0000000000..76228abab3 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('No data')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx index 0d31428bd2..8aad5b2b5c 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -57,6 +57,126 @@ type DisplayRow = { isVirtual: boolean // whether this row is the extra empty row for adding new items } +const isEmptyRow = (row: GenericTableRow) => { + return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false) +} + +const getDisplayRows = ( + data: GenericTableRow[], + emptyRowData: GenericTableRow, + readonly: boolean, +): DisplayRow[] => { + if (readonly) + return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false })) + + if (!data.length) + return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }] + + const rows = data.reduce((acc, row, index) => { + if (isEmptyRow(row) && index < data.length - 1) + return acc + + acc.push({ row, dataIndex: index, isVirtual: false }) + return acc + }, []) + + const lastRow = data.at(-1) + if (lastRow && !isEmptyRow(lastRow)) + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + + return rows +} + +const getPrimaryKey = (columns: ColumnConfig[]) => { + return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' +} + +const renderInputCell = ( + column: ColumnConfig, + value: unknown, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( + { + if (column.key === 'key' || column.key === 'name') + replaceSpaceWithUnderscoreInVarNameInput(e.target) + handleChange(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } + }} + placeholder={column.placeholder} + disabled={readonly} + wrapperClassName="w-full min-w-0" + className={cn( + 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', + 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', + 'text-text-secondary system-sm-regular placeholder:text-text-quaternary', + )} + /> + ) +} + +const renderSelectCell = ( + column: ColumnConfig, + value: unknown, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( + handleChange(item.value)} + disabled={readonly} + placeholder={column.placeholder} + hideChecked={false} + notClearable={true} + wrapperClassName="h-6 w-full min-w-0" + className={cn( + 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', + 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', + )} + optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" + /> + ) +} + +const renderSwitchCell = ( + column: ColumnConfig, + value: unknown, + dataIndex: number | null, + readonly: boolean, + handleChange: (value: unknown) => void, +) => { + return ( +
+ handleChange(!value)} + disabled={readonly} + /> +
+ ) +} + +const renderCustomCell = ( + column: ColumnConfig, + value: unknown, + row: GenericTableRow, + dataIndex: number | null, + handleChange: (value: unknown) => void, +) => { + return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null +} + const GenericTable: FC = ({ title, columns, @@ -68,42 +188,8 @@ const GenericTable: FC = ({ className, showHeader = false, }) => { - // Build the rows to display while keeping a stable mapping to original data const displayRows = useMemo(() => { - // Helper to check empty - const isEmptyRow = (r: GenericTableRow) => - Object.values(r).every(v => v === '' || v === null || v === undefined || v === false) - - if (readonly) - return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false })) - - const hasData = data.length > 0 - const rows: DisplayRow[] = [] - - if (!hasData) { - // Initialize with exactly one empty row when there is no data - rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) - return rows - } - - // Add configured rows, hide intermediate empty ones, keep mapping - data.forEach((r, i) => { - const isEmpty = isEmptyRow(r) - // Skip empty rows except the very last configured row - if (isEmpty && i < data.length - 1) - return - rows.push({ row: r, dataIndex: i, isVirtual: false }) - }) - - // If the last configured row has content, append a trailing empty row - const lastRow = data.at(-1) - if (!lastRow) - return rows - const lastHasContent = !isEmptyRow(lastRow) - if (lastHasContent) - rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) - - return rows + return getDisplayRows(data, emptyRowData, readonly) }, [data, emptyRowData, readonly]) const removeRow = useCallback((dataIndex: number) => { @@ -134,9 +220,7 @@ const GenericTable: FC = ({ }, [data, emptyRowData, onChange, readonly]) // Determine the primary identifier column just once - const primaryKey = useMemo(() => ( - columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' - ), [columns]) + const primaryKey = useMemo(() => getPrimaryKey(columns), [columns]) const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => { const value = row[column.key] @@ -144,67 +228,16 @@ const GenericTable: FC = ({ switch (column.type) { case 'input': - return ( - { - // Format variable names (replace spaces with underscores) - if (column.key === 'key' || column.key === 'name') - replaceSpaceWithUnderscoreInVarNameInput(e.target) - handleChange(e.target.value) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - e.currentTarget.blur() - } - }} - placeholder={column.placeholder} - disabled={readonly} - wrapperClassName="w-full min-w-0" - className={cn( - // Ghost/inline style: looks like plain text until focus/hover - 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', - 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', - 'text-text-secondary system-sm-regular placeholder:text-text-quaternary', - )} - /> - ) + return renderInputCell(column, value, readonly, handleChange) case 'select': - return ( - handleChange(item.value)} - disabled={readonly} - placeholder={column.placeholder} - hideChecked={false} - notClearable={true} - // wrapper provides compact height, trigger is transparent like text - wrapperClassName="h-6 w-full min-w-0" - className={cn( - 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', - 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', - )} - optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" - /> - ) + return renderSelectCell(column, value, readonly, handleChange) case 'switch': - return ( -
- handleChange(!value)} - disabled={readonly} - /> -
- ) + return renderSwitchCell(column, value, dataIndex, readonly, handleChange) case 'custom': - return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null + return renderCustomCell(column, value, row, dataIndex, handleChange) default: return null @@ -270,6 +303,7 @@ const GenericTable: FC = ({ className="p-1" aria-label="Delete row" > + {/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..8ff45ef3f9 --- /dev/null +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/hooks.spec.tsx @@ -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') + }) + }) +}) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts index 39f6a9dc5c..2c233fecda 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/toolbar/hooks.ts @@ -27,55 +27,72 @@ import { import { useNoteEditorStore } from '../store' import { getSelectedNode } from '../utils' +const DEFAULT_FONT_SIZE = '12px' + +const updateFontSizeFromSelection = (setFontSize: (fontSize: string) => void) => { + const selection = $getSelection() + if ($isRangeSelection(selection)) + setFontSize($getSelectionStyleValueForProperty(selection, 'font-size', DEFAULT_FONT_SIZE)) +} + +const toggleLink = ( + editor: ReturnType[0], + noteEditorStore: ReturnType, +) => { + editor.update(() => { + const selection = $getSelection() + + if (!$isRangeSelection(selection)) + return + + const node = getSelectedNode(selection) + const parent = node.getParent() + const { setLinkAnchorElement } = noteEditorStore.getState() + + if ($isLinkNode(parent) || $isLinkNode(node)) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + setLinkAnchorElement() + return + } + + editor.dispatchCommand(TOGGLE_LINK_COMMAND, '') + setLinkAnchorElement(true) + }) +} + +const toggleBullet = ( + editor: ReturnType[0], + selectedIsBullet: boolean, +) => { + if (!selectedIsBullet) { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) + return + } + + editor.update(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) + $setBlocksType(selection, () => $createParagraphNode()) + }) +} + export const useCommand = () => { const [editor] = useLexicalComposerContext() const noteEditorStore = useNoteEditorStore() const handleCommand = useCallback((type: string) => { - if (type === 'bold') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') - - if (type === 'italic') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') - - if (type === 'strikethrough') - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') + if (type === 'bold' || type === 'italic' || type === 'strikethrough') { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, type) + return + } if (type === 'link') { - editor.update(() => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const node = getSelectedNode(selection) - const parent = node.getParent() - const { setLinkAnchorElement } = noteEditorStore.getState() - - if ($isLinkNode(parent) || $isLinkNode(node)) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) - setLinkAnchorElement() - } - else { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, '') - setLinkAnchorElement(true) - } - } - }) + toggleLink(editor, noteEditorStore) + return } - if (type === 'bullet') { - const { selectedIsBullet } = noteEditorStore.getState() - - if (selectedIsBullet) { - editor.update(() => { - const selection = $getSelection() - if ($isRangeSelection(selection)) - $setBlocksType(selection, () => $createParagraphNode()) - }) - } - else { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) - } - } + if (type === 'bullet') + toggleBullet(editor, noteEditorStore.getState().selectedIsBullet) }, [editor, noteEditorStore]) return { @@ -85,7 +102,7 @@ export const useCommand = () => { export const useFontSize = () => { const [editor] = useLexicalComposerContext() - const [fontSize, setFontSize] = useState('12px') + const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE) const [fontSizeSelectorShow, setFontSizeSelectorShow] = useState(false) const handleFontSize = useCallback((fontSize: string) => { @@ -113,24 +130,13 @@ export const useFontSize = () => { return mergeRegister( editor.registerUpdateListener(() => { editor.getEditorState().read(() => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px') - setFontSize(fontSize) - } + updateFontSizeFromSelection(setFontSize) }) }), editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { - const selection = $getSelection() - - if ($isRangeSelection(selection)) { - const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', '12px') - setFontSize(fontSize) - } - + updateFontSizeFromSelection(setFontSize) return false }, COMMAND_PRIORITY_CRITICAL, diff --git a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e4230a1138 --- /dev/null +++ b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx @@ -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 + ? ( +
+ + +
+ ) + : null, +})) + +vi.mock('@/app/components/workflow/panel/env-panel/env-item', () => ({ + default: ({ + env, + onEdit, + onDelete, + }: { + env: EnvironmentVariable + onEdit: (env: EnvironmentVariable) => void + onDelete: (env: EnvironmentVariable) => void + }) => ( +
+ {env.name} + + +
+ ), +})) + +vi.mock('@/app/components/workflow/panel/env-panel/variable-trigger', () => ({ + default: ({ + env, + onClose, + onSave, + }: { + env?: EnvironmentVariable + onClose: () => void + onSave: (env: EnvironmentVariable) => Promise + }) => ( +
+ + +
+ ), +})) + +const createEnv = (overrides: Partial = {}): EnvironmentVariable => ({ + id: 'env-1', + name: 'api_key', + value: '[__HIDDEN__]', + value_type: 'secret', + description: 'secret description', + ...overrides, +}) + +const renderWithProviders = ( + ui: ReactElement, + storeState: Partial = {}, +) => { + const store = createWorkflowStore({}) + store.setState(storeState) + + return { + store, + ...render( + + {ui} + , + ), + } +} + +describe('EnvPanel container', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetNodes.mockReturnValue([]) + }) + + it('should close the panel from the header action', async () => { + const user = userEvent.setup() + const { container, store } = renderWithProviders(, { + environmentVariables: [], + }) + + await user.click(container.querySelector('.cursor-pointer') as HTMLElement) + + expect(store.getState().showEnvPanel).toBe(false) + }) + + it('should add variables and normalize secret values after syncing', async () => { + const user = userEvent.setup() + const { store } = renderWithProviders(, { + environmentVariables: [], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: 'Save variable' })) + + expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + expect(store.getState().environmentVariables).toEqual([ + expect.objectContaining({ + id: 'env-created', + name: 'created_name', + value: 'created-value', + }), + ]) + }) + + it('should delete unused variables and sync draft changes', async () => { + const user = userEvent.setup() + const env = createEnv({ value_type: 'string', value: 'plain-text' }) + const { store } = renderWithProviders(, { + environmentVariables: [env], + envSecrets: {}, + }) + + await user.click(screen.getByRole('button', { name: `Delete ${env.name}` })) + + expect(store.getState().environmentVariables).toEqual([]) + expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index 5213a5dba1..324552ebd9 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -19,6 +19,79 @@ import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable- import { useStore } from '@/app/components/workflow/store' import { cn } from '@/utils/classnames' +const HIDDEN_SECRET_VALUE = '[__HIDDEN__]' + +const formatSecret = (secret: string) => { + return secret.length > 8 ? `${secret.slice(0, 6)}************${secret.slice(-2)}` : '********************' +} + +const sanitizeSecretValue = (env: EnvironmentVariable) => { + return env.value_type === 'secret' + ? { ...env, value: HIDDEN_SECRET_VALUE } + : env +} + +const useEnvPanelActions = ({ + store, + envSecrets, + updateEnvList, + setEnvSecrets, + doSyncWorkflowDraft, +}: { + store: ReturnType + envSecrets: Record + updateEnvList: (envList: EnvironmentVariable[]) => void + setEnvSecrets: (envSecrets: Record) => void + doSyncWorkflowDraft: () => Promise +}) => { + const getAffectedNodes = useCallback((env: EnvironmentVariable) => { + const allNodes = store.getState().getNodes() + return findUsedVarNodes( + ['env', env.name], + allNodes, + ) + }, [store]) + + const updateAffectedNodes = useCallback((currentEnv: EnvironmentVariable, nextSelector: string[]) => { + const { getNodes, setNodes } = store.getState() + const affectedNodes = getAffectedNodes(currentEnv) + const nextNodes = getNodes().map((node) => { + if (affectedNodes.find(affectedNode => affectedNode.id === node.id)) + return updateNodeVars(node, ['env', currentEnv.name], nextSelector) + + return node + }) + setNodes(nextNodes) + }, [getAffectedNodes, store]) + + const syncEnvList = useCallback(async (nextEnvList: EnvironmentVariable[]) => { + updateEnvList(nextEnvList) + await doSyncWorkflowDraft() + updateEnvList(nextEnvList.map(sanitizeSecretValue)) + }, [doSyncWorkflowDraft, updateEnvList]) + + const saveSecretValue = useCallback((env: EnvironmentVariable) => { + setEnvSecrets({ + ...envSecrets, + [env.id]: formatSecret(String(env.value)), + }) + }, [envSecrets, setEnvSecrets]) + + const removeEnvSecret = useCallback((envId: string) => { + const nextSecrets = { ...envSecrets } + delete nextSecrets[envId] + setEnvSecrets(nextSecrets) + }, [envSecrets, setEnvSecrets]) + + return { + getAffectedNodes, + updateAffectedNodes, + syncEnvList, + saveSecretValue, + removeEnvSecret, + } +} + const EnvPanel = () => { const { t } = useTranslation() const store = useStoreApi() @@ -28,123 +101,87 @@ const EnvPanel = () => { const updateEnvList = useStore(s => s.setEnvironmentVariables) const setEnvSecrets = useStore(s => s.setEnvSecrets) const { doSyncWorkflowDraft } = useNodesSyncDraft() + const { + getAffectedNodes, + updateAffectedNodes, + syncEnvList, + saveSecretValue, + removeEnvSecret, + } = useEnvPanelActions({ + store, + envSecrets, + updateEnvList, + setEnvSecrets, + doSyncWorkflowDraft, + }) const [showVariableModal, setShowVariableModal] = useState(false) const [currentVar, setCurrentVar] = useState() - const [showRemoveVarConfirm, setShowRemoveConfirm] = useState(false) + const [showRemoveVarConfirm, setShowRemoveVarConfirm] = useState(false) const [cacheForDelete, setCacheForDelete] = useState() - const formatSecret = (s: string) => { - return s.length > 8 ? `${s.slice(0, 6)}************${s.slice(-2)}` : '********************' - } - - const getEffectedNodes = useCallback((env: EnvironmentVariable) => { - const { getNodes } = store.getState() - const allNodes = getNodes() - return findUsedVarNodes( - ['env', env.name], - allNodes, - ) - }, [store]) - - const removeUsedVarInNodes = useCallback((env: EnvironmentVariable) => { - const { getNodes, setNodes } = store.getState() - const effectedNodes = getEffectedNodes(env) - const newNodes = getNodes().map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['env', env.name], []) - - return node - }) - setNodes(newNodes) - }, [getEffectedNodes, store]) - const handleEdit = (env: EnvironmentVariable) => { setCurrentVar(env) setShowVariableModal(true) } const handleDelete = useCallback((env: EnvironmentVariable) => { - removeUsedVarInNodes(env) + updateAffectedNodes(env, []) updateEnvList(envList.filter(e => e.id !== env.id)) setCacheForDelete(undefined) - setShowRemoveConfirm(false) + setShowRemoveVarConfirm(false) doSyncWorkflowDraft() - if (env.value_type === 'secret') { - const newMap = { ...envSecrets } - delete newMap[env.id] - setEnvSecrets(newMap) - } - }, [doSyncWorkflowDraft, envList, envSecrets, removeUsedVarInNodes, setEnvSecrets, updateEnvList]) + if (env.value_type === 'secret') + removeEnvSecret(env.id) + }, [doSyncWorkflowDraft, envList, removeEnvSecret, updateAffectedNodes, updateEnvList]) const deleteCheck = useCallback((env: EnvironmentVariable) => { - const effectedNodes = getEffectedNodes(env) - if (effectedNodes.length > 0) { + const affectedNodes = getAffectedNodes(env) + if (affectedNodes.length > 0) { setCacheForDelete(env) - setShowRemoveConfirm(true) + setShowRemoveVarConfirm(true) } else { handleDelete(env) } - }, [getEffectedNodes, handleDelete]) + }, [getAffectedNodes, handleDelete]) const handleSave = useCallback(async (env: EnvironmentVariable) => { - // add env let newEnv = env if (!currentVar) { - if (env.value_type === 'secret') { - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) - } - const newList = [env, ...envList] - updateEnvList(newList) - await doSyncWorkflowDraft() - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) + if (env.value_type === 'secret') + saveSecretValue(env) + + await syncEnvList([env, ...envList]) return } - else if (currentVar.value_type === 'secret') { + + if (currentVar.value_type === 'secret') { if (env.value_type === 'secret') { if (envSecrets[currentVar.id] !== env.value) { newEnv = env - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) + saveSecretValue(env) } else { - newEnv = { ...env, value: '[__HIDDEN__]' } + newEnv = sanitizeSecretValue(env) } } } - else { - if (env.value_type === 'secret') { - newEnv = env - setEnvSecrets({ - ...envSecrets, - [env.id]: formatSecret(env.value), - }) - } + else if (env.value_type === 'secret') { + saveSecretValue(env) } - const newList = envList.map(e => e.id === currentVar.id ? newEnv : e) - updateEnvList(newList) - // side effects of rename env - if (currentVar.name !== env.name) { - const { getNodes, setNodes } = store.getState() - const effectedNodes = getEffectedNodes(currentVar) - const newNodes = getNodes().map((node) => { - if (effectedNodes.find(n => n.id === node.id)) - return updateNodeVars(node, ['env', currentVar.name], ['env', env.name]) - return node - }) - setNodes(newNodes) - } - await doSyncWorkflowDraft() - updateEnvList(newList.map(e => (e.id === env.id && env.value_type === 'secret') ? { ...e, value: '[__HIDDEN__]' } : e)) - }, [currentVar, doSyncWorkflowDraft, envList, envSecrets, getEffectedNodes, setEnvSecrets, store, updateEnvList]) + const newList = envList.map(e => e.id === currentVar.id ? newEnv : e) + if (currentVar.name !== env.name) + updateAffectedNodes(currentVar, ['env', env.name]) + + await syncEnvList(newList) + }, [currentVar, envList, envSecrets, saveSecretValue, syncEnvList, updateAffectedNodes]) + + const handleVariableModalClose = () => { + setCurrentVar(undefined) + } return (
{ className="flex h-6 w-6 cursor-pointer items-center justify-center" onClick={() => setShowEnvPanel(false)} > + {/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
@@ -170,7 +208,7 @@ const EnvPanel = () => { setOpen={setShowVariableModal} env={currentVar} onSave={handleSave} - onClose={() => setCurrentVar(undefined)} + onClose={handleVariableModalClose} />
@@ -185,7 +223,7 @@ const EnvPanel = () => {
setShowRemoveConfirm(false)} + onCancel={() => setShowRemoveVarConfirm(false)} onConfirm={() => cacheForDelete && handleDelete(cacheForDelete)} /> diff --git a/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx new file mode 100644 index 0000000000..e4bad8bbac --- /dev/null +++ b/web/app/components/workflow/run/iteration-log/__tests__/iteration-log-trigger.spec.tsx @@ -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 => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'iteration-node', + node_type: BlockEnum.Iteration, + title: 'Iteration', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: NodeRunningStatus.Succeeded, + error: '', + elapsed_time: 0.2, + metadata: { + iterator_length: 0, + iterator_index: 0, + loop_length: 0, + loop_index: 0, + }, + created_at: 1710000000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + finished_at: 1710000001, + ...overrides, +}) + +const createExecutionMetadata = (overrides: Partial> = {}) => ({ + total_tokens: 0, + total_price: 0, + currency: 'USD', + ...overrides, +}) + +describe('IterationLogTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Structured Detail Handling', () => { + it('should reconstruct structured iteration groups from execution metadata and include failed missing details', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const iterationDurationMap: IterationDurationMap = { 'parallel-1': 1.1, '1': 2.2 } + const missingFailedIteration = [ + createNodeTracing({ + id: 'failed-step', + status: NodeRunningStatus.Failed, + execution_metadata: createExecutionMetadata({ + iteration_index: 2, + }), + }), + ] + const allExecutions = [ + createNodeTracing({ + id: 'parallel-step', + execution_metadata: createExecutionMetadata({ + parallel_mode_run_id: 'parallel-1', + }), + }), + createNodeTracing({ + id: 'serial-step', + execution_metadata: createExecutionMetadata({ + iteration_id: 'iteration-node', + iteration_index: 1, + }), + }), + ] + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith( + [ + [allExecutions[0]], + [allExecutions[1]], + missingFailedIteration, + ], + iterationDurationMap, + ) + }) + + it('should fall back to details and metadata length when duration map is unavailable', async () => { + const user = userEvent.setup() + const onShowIterationResultList = vi.fn() + const detailList = [[createNodeTracing({ id: 'detail-1' })]] + + render( + , + ) + + expect(screen.getByRole('button', { name: /workflow\.nodes\.iteration\.iteration/ })).toBeInTheDocument() + + await user.click(screen.getByRole('button')) + + expect(onShowIterationResultList).toHaveBeenCalledWith(detailList, {}) + }) + }) +}) diff --git a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx index 63043e51b7..52ebb29ff5 100644 --- a/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx +++ b/web/app/components/workflow/run/iteration-log/iteration-log-trigger.tsx @@ -13,6 +13,54 @@ type IterationLogTriggerProps = { allExecutions?: NodeTracing[] onShowIterationResultList: (iterationResultList: NodeTracing[][], iterationResultDurationMap: IterationDurationMap) => void } + +const getIterationDurationMap = (nodeInfo: NodeTracing) => { + return nodeInfo.iterDurationMap || nodeInfo.execution_metadata?.iteration_duration_map || {} +} + +const getDisplayIterationCount = (nodeInfo: NodeTracing) => { + const iterationDurationMap = nodeInfo.execution_metadata?.iteration_duration_map + if (iterationDurationMap) + return Object.keys(iterationDurationMap).length + if (nodeInfo.details?.length) + return nodeInfo.details.length + return nodeInfo.metadata?.iterator_length ?? 0 +} + +const getFailedIterationIndices = ( + details: NodeTracing[][] | undefined, + nodeInfo: NodeTracing, + allExecutions?: NodeTracing[], +) => { + if (!details?.length) + return new Set() + + const failedIterationIndices = new Set() + + details.forEach((iteration, index) => { + if (!iteration.some(item => item.status === NodeRunningStatus.Failed)) + return + + const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index + failedIterationIndices.add(iterationIndex) + }) + + if (!nodeInfo.execution_metadata?.iteration_duration_map || !allExecutions) + return failedIterationIndices + + allExecutions.forEach((execution) => { + if ( + execution.execution_metadata?.iteration_id === nodeInfo.node_id + && execution.status === NodeRunningStatus.Failed + && execution.execution_metadata?.iteration_index !== undefined + ) { + failedIterationIndices.add(execution.execution_metadata.iteration_index) + } + }) + + return failedIterationIndices +} + const IterationLogTrigger = ({ nodeInfo, allExecutions, @@ -20,7 +68,7 @@ const IterationLogTrigger = ({ }: IterationLogTriggerProps) => { const { t } = useTranslation() - const filterNodesForInstance = (key: string): NodeTracing[] => { + const getNodesForInstance = (key: string): NodeTracing[] => { if (!allExecutions) return [] @@ -43,97 +91,59 @@ const IterationLogTrigger = ({ return [] } + const getStructuredIterationList = () => { + const iterationNodeMeta = nodeInfo.execution_metadata + + if (!iterationNodeMeta?.iteration_duration_map) + return nodeInfo.details || [] + + const structuredList = Object.keys(iterationNodeMeta.iteration_duration_map) + .map(getNodesForInstance) + .filter(branchNodes => branchNodes.length > 0) + + if (!allExecutions || !nodeInfo.details?.length) + return structuredList + + const existingIterationIndices = new Set() + structuredList.forEach((iteration) => { + iteration.forEach((node) => { + if (node.execution_metadata?.iteration_index !== undefined) + existingIterationIndices.add(node.execution_metadata.iteration_index) + }) + }) + + nodeInfo.details.forEach((iteration, index) => { + if ( + !existingIterationIndices.has(index) + && iteration.some(node => node.status === NodeRunningStatus.Failed) + ) { + structuredList.push(iteration) + } + }) + + return structuredList.sort((a, b) => { + const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0 + const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0 + return aIndex - bIndex + }) + } + const handleOnShowIterationDetail = (e: React.MouseEvent) => { e.stopPropagation() e.nativeEvent.stopImmediatePropagation() - const iterationNodeMeta = nodeInfo.execution_metadata - const iterDurationMap = nodeInfo?.iterDurationMap || iterationNodeMeta?.iteration_duration_map || {} - - let structuredList: NodeTracing[][] = [] - if (iterationNodeMeta?.iteration_duration_map) { - const instanceKeys = Object.keys(iterationNodeMeta.iteration_duration_map) - structuredList = instanceKeys - .map(key => filterNodesForInstance(key)) - .filter(branchNodes => branchNodes.length > 0) - - // Also include failed iterations that might not be in duration map - if (allExecutions && nodeInfo.details?.length) { - const existingIterationIndices = new Set() - structuredList.forEach((iteration) => { - iteration.forEach((node) => { - if (node.execution_metadata?.iteration_index !== undefined) - existingIterationIndices.add(node.execution_metadata.iteration_index) - }) - }) - - // Find failed iterations that are not in the structured list - nodeInfo.details.forEach((iteration, index) => { - if (!existingIterationIndices.has(index) && iteration.some(node => node.status === NodeRunningStatus.Failed)) - structuredList.push(iteration) - }) - - // Sort by iteration index to maintain order - structuredList.sort((a, b) => { - const aIndex = a[0]?.execution_metadata?.iteration_index ?? 0 - const bIndex = b[0]?.execution_metadata?.iteration_index ?? 0 - return aIndex - bIndex - }) - } - } - else if (nodeInfo.details?.length) { - structuredList = nodeInfo.details - } - - onShowIterationResultList(structuredList, iterDurationMap) + onShowIterationResultList(getStructuredIterationList(), getIterationDurationMap(nodeInfo)) } - let displayIterationCount = 0 - const iterMap = nodeInfo.execution_metadata?.iteration_duration_map - if (iterMap) - displayIterationCount = Object.keys(iterMap).length - else if (nodeInfo.details?.length) - displayIterationCount = nodeInfo.details.length - else if (nodeInfo.metadata?.iterator_length) - displayIterationCount = nodeInfo.metadata.iterator_length - - const getErrorCount = (details: NodeTracing[][] | undefined, iterationNodeMeta?: any) => { - if (!details || details.length === 0) - return 0 - - // Use Set to track failed iteration indices to avoid duplicate counting - const failedIterationIndices = new Set() - - // Collect failed iteration indices from details - details.forEach((iteration, index) => { - if (iteration.some(item => item.status === NodeRunningStatus.Failed)) { - // Try to get iteration index from first node, fallback to array index - const iterationIndex = iteration[0]?.execution_metadata?.iteration_index ?? index - failedIterationIndices.add(iterationIndex) - } - }) - - // If allExecutions exists, check for additional failed iterations - if (iterationNodeMeta?.iteration_duration_map && allExecutions) { - // Find all failed iteration nodes - allExecutions.forEach((exec) => { - if (exec.execution_metadata?.iteration_id === nodeInfo.node_id - && exec.status === NodeRunningStatus.Failed - && exec.execution_metadata?.iteration_index !== undefined) { - failedIterationIndices.add(exec.execution_metadata.iteration_index) - } - }) - } - - return failedIterationIndices.size - } - const errorCount = getErrorCount(nodeInfo.details, nodeInfo.execution_metadata) + const displayIterationCount = getDisplayIterationCount(nodeInfo) + const errorCount = getFailedIterationIndices(nodeInfo.details, nodeInfo, allExecutions).size return ( )