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