feat(workflow): add selection context menu helpers and integrate with context menu component

- Introduced helper functions for managing alignment and distribution of nodes within the workflow.
- Created a new file for selection context menu helpers, encapsulating logic for menu positioning and node alignment.
- Updated the SelectionContextmenu component to utilize the new helpers, improving code organization and readability.
- Added unit tests for the new helper functions to ensure functionality and correctness.
This commit is contained in:
CodingOnStar
2026-03-24 17:53:48 +08:00
parent 6633f5aef8
commit 1943785c1c
18 changed files with 3341 additions and 965 deletions

View File

@ -0,0 +1,136 @@
import {
alignNodePosition,
AlignType,
distributeNodes,
getAlignableNodes,
getAlignBounds,
getMenuPosition,
} from '../selection-contextmenu.helpers'
import { createNode } from './fixtures'
describe('selection-contextmenu helpers', () => {
it('should keep the menu inside the workflow container bounds', () => {
expect(getMenuPosition(undefined, { width: 800, height: 600 })).toEqual({
left: 0,
top: 0,
})
expect(getMenuPosition({ left: 780, top: 590 }, { width: 800, height: 600 })).toEqual({
left: 540,
top: 210,
})
expect(getMenuPosition({ left: -10, top: -20 }, { width: 800, height: 600 })).toEqual({
left: 0,
top: 0,
})
})
it('should exclude child nodes when their container node is selected', () => {
const container = createNode({
id: 'container',
selected: true,
data: {
_children: [{ nodeId: 'child', nodeType: 'code' as never }],
},
})
const child = createNode({ id: 'child', selected: true })
const other = createNode({ id: 'other', selected: true })
expect(getAlignableNodes([container, child, other], [container, child, other]).map(node => node.id)).toEqual([
'container',
'other',
])
})
it('should calculate bounds and align nodes by type', () => {
const leftNode = createNode({
id: 'left',
position: { x: 10, y: 30 },
positionAbsolute: { x: 10, y: 30 },
width: 40,
height: 20,
})
const rightNode = createNode({
id: 'right',
position: { x: 100, y: 70 },
positionAbsolute: { x: 100, y: 70 },
width: 60,
height: 40,
})
const bounds = getAlignBounds([leftNode, rightNode])
expect(bounds).toEqual({
minX: 10,
maxX: 160,
minY: 30,
maxY: 110,
})
alignNodePosition(rightNode, rightNode, AlignType.Left, bounds!)
expect(rightNode.position.x).toBe(10)
alignNodePosition(rightNode, rightNode, AlignType.Center, bounds!)
expect(rightNode.position.x).toBe(55)
alignNodePosition(rightNode, rightNode, AlignType.Right, bounds!)
expect(rightNode.position.x).toBe(100)
alignNodePosition(leftNode, leftNode, AlignType.Top, bounds!)
expect(leftNode.position.y).toBe(30)
alignNodePosition(leftNode, leftNode, AlignType.Middle, bounds!)
expect(leftNode.position.y).toBe(60)
expect(leftNode.positionAbsolute?.y).toBe(60)
alignNodePosition(leftNode, leftNode, AlignType.Bottom, bounds!)
expect(leftNode.position.y).toBe(90)
expect(leftNode.positionAbsolute?.y).toBe(90)
})
it('should distribute nodes horizontally and vertically', () => {
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 })
const middle = createNode({ id: 'middle', position: { x: 100, y: 80 }, width: 20, height: 20 })
const last = createNode({ id: 'last', position: { x: 300, y: 200 }, width: 20, height: 20 })
const horizontal = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal)
expect(horizontal?.find(node => node.id === 'middle')?.position.x).toBe(150)
const vertical = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeVertical)
expect(vertical?.find(node => node.id === 'middle')?.position.y).toBe(100)
})
it('should return null when nodes cannot be evenly distributed', () => {
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 100, height: 100 })
const middle = createNode({ id: 'middle', position: { x: 50, y: 50 }, width: 100, height: 100 })
const last = createNode({ id: 'last', position: { x: 120, y: 120 }, width: 100, height: 100 })
expect(distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal)).toBeNull()
expect(distributeNodes([first, middle], [first, middle], AlignType.DistributeVertical)).toBeNull()
expect(getAlignBounds([first])).toBeNull()
})
it('should skip missing draft nodes and keep absolute positions in vertical distribution', () => {
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 })
const middle = createNode({
id: 'middle',
position: { x: 0, y: 60 },
positionAbsolute: { x: 0, y: 60 },
width: 20,
height: 20,
})
const last = createNode({ id: 'last', position: { x: 0, y: 180 }, width: 20, height: 20 })
const distributed = distributeNodes(
[first, middle, last],
[first, last],
AlignType.DistributeVertical,
)
expect(distributed).toEqual([first, last])
const distributedWithMiddle = distributeNodes(
[first, middle, last],
[first, middle, last],
AlignType.DistributeVertical,
)
expect(distributedWithMiddle?.find(node => node.id === 'middle')?.positionAbsolute?.y).toBe(90)
})
})

View File

@ -0,0 +1,200 @@
import type { Edge, Node } from '../types'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { useNodes } from 'reactflow'
import SelectionContextmenu from '../selection-contextmenu'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { createEdge, createNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
let latestNodes: Node[] = []
let latestHistoryEvent: string | undefined
const RuntimeProbe = () => {
latestNodes = useNodes() as Node[]
const { store } = useWorkflowHistoryStore()
useEffect(() => {
latestHistoryEvent = store.getState().workflowHistoryEvent
return store.subscribe((state) => {
latestHistoryEvent = state.workflowHistoryEvent
})
}, [store])
return null
}
const hooksStoreProps = {
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
}
const renderSelectionMenu = (options?: {
nodes?: Node[]
edges?: Edge[]
initialStoreState?: Record<string, unknown>
}) => {
latestNodes = []
latestHistoryEvent = undefined
const nodes = options?.nodes ?? []
const edges = options?.edges ?? []
return renderWorkflowFlowComponent(
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<RuntimeProbe />
<SelectionContextmenu />
</div>,
{
nodes,
edges,
hooksStoreProps,
historyStore: { nodes, edges },
initialStoreState: options?.initialStoreState,
reactFlowProps: { fitView: false },
},
)
}
describe('SelectionContextmenu', () => {
beforeEach(() => {
vi.clearAllMocks()
latestNodes = []
latestHistoryEvent = undefined
})
it('should not render when selectionMenu is absent', () => {
renderSelectionMenu()
expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument()
})
it('should keep the menu inside the workflow container bounds', () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 780, top: 590 } })
})
const menu = screen.getByTestId('selection-contextmenu')
expect(menu).toHaveStyle({ left: '540px', top: '210px' })
})
it('should close itself when only one node is selected', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 120, top: 120 } })
})
await waitFor(() => {
expect(store.getState().selectionMenu).toBeUndefined()
})
})
it('should align selected nodes to the left and save history', async () => {
vi.useFakeTimers()
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }),
]
const { store } = renderSelectionMenu({
nodes,
edges: [createEdge({ source: 'n1', target: 'n2' })],
initialStoreState: {
helpLineHorizontal: { y: 10 } as never,
helpLineVertical: { x: 10 } as never,
},
})
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20)
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20)
expect(store.getState().selectionMenu).toBeUndefined()
expect(store.getState().helpLineHorizontal).toBeUndefined()
expect(store.getState().helpLineVertical).toBeUndefined()
act(() => {
store.getState().flushPendingSync()
vi.advanceTimersByTime(600)
})
expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled()
expect(latestHistoryEvent).toBe('NodeDragStop')
vi.useRealTimers()
})
it('should distribute selected nodes horizontally', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }),
createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }),
]
const { store } = renderSelectionMenu({
nodes,
})
act(() => {
store.setState({ selectionMenu: { left: 160, top: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal'))
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150)
})
it('should ignore child nodes when the selected container is aligned', async () => {
const nodes = [
createNode({
id: 'container',
selected: true,
position: { x: 200, y: 0 },
width: 100,
height: 80,
data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] },
}),
createNode({
id: 'child',
selected: true,
position: { x: 210, y: 10 },
width: 30,
height: 20,
}),
createNode({
id: 'other',
selected: true,
position: { x: 40, y: 60 },
width: 40,
height: 20,
}),
]
const { store } = renderSelectionMenu({
nodes,
})
act(() => {
store.setState({ selectionMenu: { left: 180, top: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40)
expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40)
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210)
})
})

View File

@ -0,0 +1,410 @@
import type { ComponentProps } from 'react'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarKindType } from '../../types'
import FormInputItem from '../form-input-item'
const {
mockFetchDynamicOptions,
mockTriggerDynamicOptionsState,
} = vi.hoisted(() => ({
mockFetchDynamicOptions: vi.fn(),
mockTriggerDynamicOptionsState: {
data: undefined as { options: FormOption[] } | undefined,
isLoading: false,
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/service/use-plugins', () => ({
useFetchDynamicOptions: () => ({
mutateAsync: mockFetchDynamicOptions,
}),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
<button onClick={() => onSelect('app-1')}>app-selector</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (value: string) => void }) => (
<button onClick={() => setModel('model-1')}>model-selector</button>
),
}))
vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({
default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => (
<input aria-label="mixed-variable-input" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => (
<textarea aria-label="json-editor" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
<button onClick={() => onChange(['node-2', 'asset'])}>variable-picker</button>
),
}))
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
const renderFormInputItem = (props: Partial<ComponentProps<typeof FormInputItem>> = {}) => {
const onChange = vi.fn()
const result = renderWorkflowFlowComponent(
<FormInputItem
readOnly={false}
nodeId="node-1"
schema={createSchema()}
value={{
field: {
type: VarKindType.constant,
value: '',
},
}}
onChange={onChange}
{...props}
/>,
{
edges: [],
hooksStoreProps: {},
nodes: [],
},
)
return { ...result, onChange }
}
describe('FormInputItem branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchDynamicOptions.mockResolvedValue({ options: [] })
mockTriggerDynamicOptionsState.data = undefined
mockTriggerDynamicOptionsState.isLoading = false
})
it('should update mixed string inputs via the shared text input', () => {
const { onChange } = renderFormInputItem()
fireEvent.change(screen.getByLabelText('mixed-variable-input'), { target: { value: 'hello world' } })
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.mixed,
value: 'hello world',
},
})
})
it('should switch from variable mode back to constant mode with the schema default value', () => {
const { container, onChange } = renderFormInputItem({
schema: createSchema({
default: 7 as never,
type: FormTypeEnum.textNumber,
}),
value: {
field: {
type: VarKindType.variable,
value: ['node-1', 'count'],
},
},
})
const switchRoot = container.querySelector('.inline-flex.h-8.shrink-0.gap-px')
const clickableItems = switchRoot?.querySelectorAll('.cursor-pointer') ?? []
fireEvent.click(clickableItems[1] as HTMLElement)
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 7,
},
})
})
it('should render static select options with icons and update the selected item', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.select,
options: [
createOption('basic', { icon: '/basic.svg' }),
createOption('pro'),
],
}),
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
fireEvent.click(screen.getByRole('button'))
expect(document.querySelector('img[src="/basic.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('basic'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'basic',
},
})
})
it('should render static multi-select values and update selected labels', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
multiple: true,
type: FormTypeEnum.select,
options: [
createOption('alpha'),
createOption('beta'),
],
}),
value: {
field: {
type: VarKindType.constant,
value: ['alpha'],
},
},
})
expect(screen.getByText('alpha')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('beta'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['alpha', 'beta'],
},
})
})
it('should fetch tool dynamic options, render them, and update the value', async () => {
mockFetchDynamicOptions.mockResolvedValueOnce({
options: [
createOption('remote', { icon: '/remote.svg' }),
],
})
const { onChange } = renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
providerType: PluginCategoryEnum.tool,
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
await waitFor(() => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole('button'))
expect(document.querySelector('img[src="/remote.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('remote'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'remote',
},
})
})
it('should recover when fetching dynamic tool options fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchDynamicOptions.mockRejectedValueOnce(new Error('network'))
renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
providerType: PluginCategoryEnum.tool,
})
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled()
})
consoleSpy.mockRestore()
})
it('should use trigger dynamic options for multi-select values', async () => {
mockTriggerDynamicOptionsState.data = {
options: [
createOption('trigger-option'),
],
}
const { onChange } = renderFormInputItem({
schema: createSchema({
multiple: true,
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-2', name: 'provider-2', credential_id: 'credential-1' } as never,
currentTool: { name: 'trigger-tool' } as never,
providerType: PluginCategoryEnum.trigger,
value: {
field: {
type: VarKindType.constant,
value: [],
},
},
})
await waitFor(() => {
expect(screen.getByRole('button')).not.toBeDisabled()
})
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('trigger-option'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['trigger-option'],
},
})
})
it('should delegate app and model selection to their dedicated controls', () => {
const app = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.appSelector }),
})
fireEvent.click(screen.getByText('app-selector'))
expect(app.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'app-1',
},
})
app.unmount()
const model = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.modelSelector }),
})
fireEvent.click(screen.getByText('model-selector'))
expect(model.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'model-1',
},
})
})
it('should render the JSON editor and variable picker specialized branches', () => {
const json = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.object }),
value: {
field: {
type: VarKindType.constant,
value: '{"enabled":false}',
},
},
})
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"enabled":true}' } })
expect(json.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: '{"enabled":true}',
},
})
json.unmount()
const picker = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.file }),
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
fireEvent.click(screen.getByText('variable-picker'))
expect(picker.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.variable,
value: ['node-2', 'asset'],
},
})
})
it('should render variable selectors for boolean variable inputs', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.boolean,
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.variable,
value: ['node-3', 'flag'],
},
},
})
fireEvent.click(screen.getByText('variable-picker'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.variable,
value: ['node-2', 'asset'],
},
})
})
})

View File

@ -0,0 +1,166 @@
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Var } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { VarKindType } from '../../types'
import {
filterVisibleOptions,
getCheckboxListOptions,
getCheckboxListValue,
getFilterVar,
getFormInputState,
getNumberInputValue,
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from '../form-input-item.helpers'
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
describe('form-input-item helpers', () => {
it('should derive field state and target var type', () => {
const numberState = getFormInputState(
createSchema({ type: FormTypeEnum.textNumber }),
{ type: VarKindType.constant, value: 1 },
)
const filesState = getFormInputState(
createSchema({ type: FormTypeEnum.files }),
{ type: VarKindType.variable, value: ['node', 'files'] },
)
expect(numberState.isNumber).toBe(true)
expect(numberState.showTypeSwitch).toBe(true)
expect(getTargetVarType(numberState)).toBe(VarType.number)
expect(filesState.isFile).toBe(true)
expect(filesState.showVariableSelector).toBe(true)
expect(getTargetVarType(filesState)).toBe(VarType.arrayFile)
})
it('should return filter functions and var kind types by schema mode', () => {
const stringFilter = getFilterVar(getFormInputState(createSchema(), { type: VarKindType.mixed, value: '' }))
const booleanState = getFormInputState(
createSchema({ _type: FormTypeEnum.boolean, type: FormTypeEnum.textInput }),
{ type: VarKindType.constant, value: true },
)
expect(stringFilter?.({ type: VarType.secret } as Var)).toBe(true)
expect(stringFilter?.({ type: VarType.file } as Var)).toBe(false)
expect(getVarKindType(booleanState)).toBe(VarKindType.constant)
expect(getFilterVar(booleanState)?.({ type: VarType.boolean } as Var)).toBe(false)
const fileState = getFormInputState(
createSchema({ type: FormTypeEnum.file }),
{ type: VarKindType.variable, value: ['node', 'file'] },
)
const objectState = getFormInputState(
createSchema({ type: FormTypeEnum.object }),
{ type: VarKindType.constant, value: '{}' },
)
const arrayState = getFormInputState(
createSchema({ type: FormTypeEnum.array }),
{ type: VarKindType.constant, value: '[]' },
)
const dynamicState = getFormInputState(
createSchema({ type: FormTypeEnum.dynamicSelect }),
{ type: VarKindType.constant, value: 'selected' },
)
expect(getFilterVar(fileState)?.({ type: VarType.file } as Var)).toBe(true)
expect(getFilterVar(objectState)?.({ type: VarType.object } as Var)).toBe(true)
expect(getFilterVar(arrayState)?.({ type: VarType.arrayString } as Var)).toBe(true)
expect(getVarKindType(fileState)).toBe(VarKindType.variable)
expect(getVarKindType(dynamicState)).toBe(VarKindType.constant)
expect(getVarKindType(getFormInputState(createSchema({ type: FormTypeEnum.appSelector }), undefined))).toBeUndefined()
})
it('should filter and map visible options using show_on rules', () => {
const options = [
createOption('always'),
createOption('premium', {
show_on: [{ variable: 'mode', value: 'pro' }],
}),
]
const values = {
mode: {
type: VarKindType.constant,
value: 'pro',
},
}
const visibleOptions = filterVisibleOptions(options, values)
expect(visibleOptions).toHaveLength(2)
expect(mapSelectItems(visibleOptions, 'en_US')).toEqual([
{ name: 'always', value: 'always' },
{ name: 'premium', value: 'premium' },
])
expect(hasOptionIcon(visibleOptions)).toBe(false)
})
it('should compute selected labels and checkbox state from visible options', () => {
const options = [
createOption('alpha'),
createOption('beta'),
createOption('gamma'),
]
expect(getSelectedLabels(['alpha', 'beta'], options, 'en_US')).toBe('alpha, beta')
expect(getSelectedLabels(['alpha', 'beta', 'gamma'], options, 'en_US')).toBe('3 selected')
expect(getCheckboxListOptions(options, 'en_US')).toEqual([
{ label: 'alpha', value: 'alpha' },
{ label: 'beta', value: 'beta' },
{ label: 'gamma', value: 'gamma' },
])
expect(getCheckboxListValue(['alpha', 'missing'], ['beta'], options)).toEqual(['alpha'])
})
it('should normalize number and variable selector values', () => {
expect(getNumberInputValue(Number.NaN)).toBe('')
expect(getNumberInputValue(2)).toBe(2)
expect(getNumberInputValue('3')).toBe('3')
expect(getNumberInputValue(undefined)).toBe('')
expect(normalizeVariableSelectorValue([])).toEqual([])
expect(normalizeVariableSelectorValue(['node', 'answer'])).toEqual(['node', 'answer'])
expect(normalizeVariableSelectorValue('')).toBe('')
})
it('should derive remaining target variable types and label states', () => {
const objectState = getFormInputState(createSchema({ type: FormTypeEnum.object }), undefined)
const arrayState = getFormInputState(createSchema({ type: FormTypeEnum.array }), undefined)
expect(getTargetVarType(objectState)).toBe(VarType.object)
expect(getTargetVarType(arrayState)).toBe(VarType.arrayObject)
expect(getSelectedLabels(undefined, [], 'en_US')).toBe('')
expect(getCheckboxListValue('alpha', [], [createOption('alpha')])).toEqual(['alpha'])
})
})

View File

@ -0,0 +1,60 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
JsonEditorField,
MultiSelectField,
} from '../form-input-item.sections'
describe('form-input-item sections', () => {
it('should render a loading multi-select label', () => {
renderWorkflowComponent(
<MultiSelectField
disabled={false}
isLoading
items={[{ name: 'Alpha', value: 'alpha' }]}
onChange={vi.fn()}
selectedLabel=""
value={[]}
/>,
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('should render the shared json editor section', () => {
renderWorkflowComponent(
<JsonEditorField
value={'{"enabled":true}'}
onChange={vi.fn()}
placeholder={<div>JSON placeholder</div>}
/>,
)
expect(screen.getByText('JSON')).toBeInTheDocument()
})
it('should render placeholder, icons, and select multi-select options', () => {
const onChange = vi.fn()
renderWorkflowComponent(
<MultiSelectField
disabled={false}
items={[
{ name: 'Alpha', value: 'alpha', icon: '/alpha.svg' },
{ name: 'Beta', value: 'beta' },
]}
onChange={onChange}
placeholder="Choose options"
selectedLabel=""
value={[]}
/>,
)
expect(screen.getByText('Choose options')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Alpha'))
expect(document.querySelector('img[src="/alpha.svg"]')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,148 @@
import type { ComponentProps } from 'react'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarKindType } from '../../types'
import FormInputItem from '../form-input-item'
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
const renderFormInputItem = (props: Partial<ComponentProps<typeof FormInputItem>> = {}) => {
const onChange = vi.fn()
renderWorkflowFlowComponent(
<FormInputItem
readOnly={false}
nodeId="node-1"
schema={createSchema()}
value={{
field: {
type: VarKindType.constant,
value: '',
},
}}
onChange={onChange}
{...props}
/>,
{
edges: [],
hooksStoreProps: {},
nodes: [],
},
)
return { onChange }
}
describe('FormInputItem', () => {
it('should parse number inputs as numbers', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.textNumber }),
value: {
field: {
type: VarKindType.constant,
value: 1,
},
},
})
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3.5' } })
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 3.5,
},
})
})
it('should toggle boolean fields using the shared boolean input', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.boolean,
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.constant,
value: true,
},
},
})
fireEvent.click(screen.getByText('False'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: false,
},
})
})
it('should filter checkbox options by show_on and update selected values', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.checkbox,
options: [
createOption('basic'),
createOption('pro', {
show_on: [{ variable: 'mode', value: 'pro' }],
}),
],
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.constant,
value: ['basic'],
},
mode: {
type: VarKindType.constant,
value: 'pro',
},
},
})
fireEvent.click(screen.getByText('pro'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['basic', 'pro'],
},
mode: {
type: VarKindType.constant,
value: 'pro',
},
})
})
})

View File

@ -0,0 +1,259 @@
'use client'
import type { ResourceVarInputs } from '../types'
import type {
CredentialFormSchema,
FormOption,
TypeWithI18N,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { VarKindType } from '../types'
type FormInputSchema = CredentialFormSchema & Partial<{
_type: FormTypeEnum
multiple: boolean
options: FormOption[]
placeholder: TypeWithI18N
scope: string
}>
type FormInputValue = ResourceVarInputs[string] | undefined
type ShowOnCondition = {
value: unknown
variable: string
}
type OptionLabel = string | TypeWithI18N
type SelectableOption = {
icon?: string
label: OptionLabel
show_on?: ShowOnCondition[]
value: string
}
export type SelectItem = {
icon?: string
name: string
value: string
}
export type FormInputState = {
defaultValue: unknown
isAppSelector: boolean
isArray: boolean
isBoolean: boolean
isCheckbox: boolean
isConstant: boolean
isDynamicSelect: boolean
isFile: boolean
isFiles: boolean
isModelSelector: boolean
isMultipleSelect: boolean
isNumber: boolean
isObject: boolean
isSelect: boolean
isShowJSONEditor: boolean
isString: boolean
options: FormOption[]
placeholder?: TypeWithI18N
scope?: string
showVariableSelector: boolean
showTypeSwitch: boolean
variable: string
}
const optionMatchesValue = (
values: ResourceVarInputs,
showOnItem: ShowOnCondition,
) => values[showOnItem.variable]?.value === showOnItem.value || values[showOnItem.variable] === showOnItem.value
const getOptionLabel = (option: SelectableOption, language: string) => {
if (typeof option.label === 'string')
return option.label
return option.label[language] || option.label.en_US || option.value
}
export const getFormInputState = (
schema: FormInputSchema,
varInput: FormInputValue,
): FormInputState => {
const {
default: defaultValue,
multiple = false,
options = [],
placeholder,
scope,
type,
variable,
_type,
} = schema
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isFiles = type === FormTypeEnum.files
const isBoolean = _type === FormTypeEnum.boolean
const isCheckbox = _type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
return {
defaultValue,
isAppSelector,
isArray,
isBoolean,
isCheckbox,
isConstant,
isDynamicSelect,
isFile,
isFiles,
isModelSelector,
isMultipleSelect,
isNumber,
isObject,
isSelect,
isShowJSONEditor,
isString,
options,
placeholder,
scope,
showTypeSwitch,
showVariableSelector,
variable,
}
}
export const getTargetVarType = (state: FormInputState) => {
if (state.isString)
return VarType.string
if (state.isNumber)
return VarType.number
if (state.isFile)
return state.isFiles ? VarType.arrayFile : VarType.file
if (state.isSelect)
return VarType.string
if (state.isBoolean)
return VarType.boolean
if (state.isObject)
return VarType.object
if (state.isArray)
return VarType.arrayObject
return VarType.string
}
export const getFilterVar = (state: FormInputState) => {
if (state.isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
if (state.isString)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
if (state.isFile)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
if (state.isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
if (state.isObject)
return (varPayload: Var) => varPayload.type === VarType.object
if (state.isArray)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
export const getVarKindType = (state: FormInputState) => {
if (state.isFile)
return VarKindType.variable
if (state.isSelect || state.isDynamicSelect || state.isBoolean || state.isNumber || state.isArray || state.isObject)
return VarKindType.constant
if (state.isString)
return VarKindType.mixed
return undefined
}
export const filterVisibleOptions = (
options: SelectableOption[],
values: ResourceVarInputs,
) => options.filter((option) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => optionMatchesValue(values, showOnItem))
return true
})
export const mapSelectItems = (
options: SelectableOption[],
language: string,
): SelectItem[] => options.map(option => ({
icon: option.icon,
name: getOptionLabel(option, language),
value: option.value,
}))
export const hasOptionIcon = (options: SelectableOption[]) => options.some(option => !!option.icon)
export const getSelectedLabels = (
selectedValues: string[] | undefined,
options: SelectableOption[],
language: string,
) => {
if (!selectedValues?.length)
return ''
const selectedOptions = options.filter(option => selectedValues.includes(option.value))
if (selectedOptions.length <= 2) {
return selectedOptions
.map(option => getOptionLabel(option, language))
.join(', ')
}
return `${selectedOptions.length} selected`
}
export const getCheckboxListOptions = (
options: SelectableOption[],
language: string,
) => options.map(option => ({
label: getOptionLabel(option, language),
value: option.value,
}))
export const getCheckboxListValue = (
currentValue: unknown,
defaultValue: unknown,
availableOptions: SelectableOption[],
) => {
let current: string[] = []
if (Array.isArray(currentValue))
current = currentValue as string[]
else if (typeof currentValue === 'string')
current = [currentValue]
else if (Array.isArray(defaultValue))
current = defaultValue as string[]
const allowedValues = new Set(availableOptions.map(option => option.value))
return current.filter(item => allowedValues.has(item))
}
export const getNumberInputValue = (currentValue: unknown): number | string => {
if (typeof currentValue === 'number')
return Number.isNaN(currentValue) ? '' : currentValue
if (typeof currentValue === 'string')
return currentValue
return ''
}
export const normalizeVariableSelectorValue = (value: ValueSelector | string) =>
value || ''

View File

@ -0,0 +1,129 @@
'use client'
import type { FC, ReactElement } from 'react'
import type { SelectItem } from './form-input-item.helpers'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { cn } from '@/utils/classnames'
type MultiSelectFieldProps = {
disabled: boolean
isLoading?: boolean
items: SelectItem[]
onChange: (value: string[]) => void
placeholder?: string
selectedLabel: string
value: string[]
}
const LoadingIndicator = () => (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
)
const ToggleIndicator = () => (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)
const SelectedMark = () => (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)
export const MultiSelectField: FC<MultiSelectFieldProps> = ({
disabled,
isLoading = false,
items,
onChange,
placeholder,
selectedLabel,
value,
}) => {
const textClassName = cn(
'block truncate text-left system-sm-regular',
isLoading
? 'text-components-input-text-placeholder'
: value.length > 0
? 'text-components-input-text-filled'
: 'text-components-input-text-placeholder',
)
const renderLabel = () => {
if (isLoading)
return 'Loading...'
return selectedLabel || placeholder || 'Select options'
}
return (
<Listbox multiple value={value} onChange={onChange} disabled={disabled}>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={textClassName}>
{renderLabel()}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading ? <LoadingIndicator /> : <ToggleIndicator />}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{items.map(item => (
<ListboxOption
key={item.value}
value={item.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{item.name}
</span>
</div>
{selected && <SelectedMark />}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)
}
type JsonEditorFieldProps = {
onChange: (value: string) => void
placeholder?: ReactElement | string
value: string
}
export const JsonEditorField: FC<JsonEditorFieldProps> = ({
onChange,
placeholder,
value,
}) => {
return (
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={value}
isExpand
isInNode
language={CodeLanguage.json}
onChange={onChange}
className="w-full"
placeholder={placeholder}
/>
</div>
)
}

View File

@ -1,27 +1,20 @@
'use client'
import type { FC } from 'react'
import type { ResourceVarInputs } from '../types'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CredentialFormSchema, FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Event, Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
import { VarType } from '@/app/components/workflow/types'
import { useFetchDynamicOptions } from '@/service/use-plugins'
@ -29,6 +22,24 @@ import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import { cn } from '@/utils/classnames'
import { VarKindType } from '../types'
import FormInputBoolean from './form-input-boolean'
import {
filterVisibleOptions,
getCheckboxListOptions,
getCheckboxListValue,
getFilterVar,
getFormInputState,
getNumberInputValue,
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from './form-input-item.helpers'
import {
JsonEditorField,
MultiSelectField,
} from './form-input-item.sections'
import FormInputTypeSwitch from './form-input-type-switch'
type Props = {
@ -66,33 +77,34 @@ const FormInputItem: FC<Props> = ({
const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null)
const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false)
const formState = getFormInputState(schema as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
scope?: string
}, value[schema.variable])
const {
placeholder,
variable,
type,
_type,
default: defaultValue,
defaultValue,
isAppSelector,
isBoolean,
isCheckbox,
isConstant,
isDynamicSelect,
isModelSelector,
isMultipleSelect,
isNumber,
isSelect,
isShowJSONEditor,
isString,
options,
multiple,
placeholder,
scope,
} = schema as any
showTypeSwitch,
showVariableSelector,
variable,
} = formState
const varInput = value[variable]
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = _type === FormTypeEnum.boolean
const isCheckbox = _type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
@ -101,56 +113,6 @@ const FormInputItem: FC<Props> = ({
},
})
const targetVarType = () => {
if (isString)
return VarType.string
else if (isNumber)
return VarType.number
else if (type === FormTypeEnum.files)
return VarType.arrayFile
else if (type === FormTypeEnum.file)
return VarType.file
else if (isSelect)
return VarType.string
// else if (isAppSelector)
// return VarType.appSelector
// else if (isModelSelector)
// return VarType.modelSelector
else if (isBoolean)
return VarType.boolean
else if (isObject)
return VarType.object
else if (isArray)
return VarType.arrayObject
else
return VarType.string
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
const getVarKindType = () => {
if (isFile)
return VarKindType.variable
if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject)
return VarKindType.constant
if (isString)
return VarKindType.mixed
}
// Fetch dynamic options hook for tools
const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions(
currentProvider?.plugin_id || '',
@ -238,30 +200,12 @@ const FormInputItem: FC<Props> = ({
...value,
[variable]: {
...varInput,
type: getVarKindType(),
type: getVarKindType(formState),
value: isNumber ? Number.parseFloat(newValue) : newValue,
},
})
}
const getSelectedLabels = (selectedValues: any[]) => {
if (!selectedValues || selectedValues.length === 0)
return ''
const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || [])
const selectedOptions = optionsList.filter((opt: any) =>
selectedValues.includes(opt.value),
)
if (selectedOptions.length <= 2) {
return selectedOptions
.map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value)
.join(', ')
}
return `${selectedOptions.length} selected`
}
const handleAppOrModelSelect = (newValue: any) => {
onChange({
...value,
@ -278,38 +222,44 @@ const FormInputItem: FC<Props> = ({
[variable]: {
...varInput,
type: VarKindType.variable,
value: newValue || '',
value: normalizeVariableSelectorValue(newValue),
},
})
}
const availableCheckboxOptions = useMemo(() => (
(options || []).filter((option: { show_on?: Array<{ variable: string, value: any }> }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value)
return true
})
), [options, value])
const availableCheckboxOptions = useMemo(
() => filterVisibleOptions(options, value),
[options, value],
)
const checkboxListOptions = useMemo(
() => getCheckboxListOptions(availableCheckboxOptions, language),
[availableCheckboxOptions, language],
)
const checkboxListValue = useMemo(
() => getCheckboxListValue(varInput?.value, defaultValue, availableCheckboxOptions),
[availableCheckboxOptions, defaultValue, varInput?.value],
)
const checkboxListOptions = useMemo(() => (
availableCheckboxOptions.map((option: { value: string, label: Record<string, string> }) => ({
value: option.value,
label: option.label?.[language] || option.label?.en_US || option.value,
}))
), [availableCheckboxOptions, language])
const checkboxListValue = useMemo(() => {
let current: string[] = []
if (Array.isArray(varInput?.value))
current = varInput.value as string[]
else if (typeof varInput?.value === 'string')
current = [varInput.value as string]
else if (Array.isArray(defaultValue))
current = defaultValue as string[]
const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value))
return current.filter(item => allowedValues.has(item))
}, [varInput?.value, defaultValue, availableCheckboxOptions])
const visibleSelectOptions = useMemo(
() => filterVisibleOptions(options, value),
[options, value],
)
const visibleDynamicOptions = useMemo(
() => filterVisibleOptions(dynamicOptions || options || [], value),
[dynamicOptions, options, value],
)
const staticSelectItems = useMemo(
() => mapSelectItems(visibleSelectOptions, language),
[language, visibleSelectOptions],
)
const dynamicSelectItems = useMemo(
() => mapSelectItems(visibleDynamicOptions, language),
[language, visibleDynamicOptions],
)
const selectedLabels = useMemo(
() => getSelectedLabels(varInput?.value as string[] | undefined, isDynamicSelect ? visibleDynamicOptions : visibleSelectOptions, language),
[isDynamicSelect, language, varInput?.value, visibleDynamicOptions, visibleSelectOptions],
)
const handleCheckboxListChange = (selected: string[]) => {
onChange({
@ -343,7 +293,7 @@ const FormInputItem: FC<Props> = ({
<Input
className="h-8 grow"
type="number"
value={Number.isNaN(varInput?.value) ? '' : varInput?.value}
value={getNumberInputValue(varInput?.value)}
onChange={e => handleValueChange(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -368,20 +318,11 @@ const FormInputItem: FC<Props> = ({
<SimpleSelect
wrapperClassName="h-8 grow"
disabled={readOnly}
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
icon: option.icon,
}))}
defaultValue={varInput?.value as string | undefined}
items={staticSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
renderOption={options.some((opt: any) => opt.icon)
renderOption={hasOptionIcon(visibleSelectOptions)
? ({ item }) => (
<div className="flex items-center">
{item.icon && (
@ -394,74 +335,21 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isSelect && isConstant && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
<MultiSelectField
disabled={readOnly}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('system-sm-regular block truncate text-left', varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder')}>
{getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{options.filter((option: { show_on: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{option.label[language] || option.label.en_US}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
value={(varInput?.value as string[] | undefined) || []}
items={staticSelectItems}
onChange={handleValueChange}
placeholder={placeholder?.[language] || placeholder?.en_US}
selectedLabel={selectedLabels}
/>
)}
{isDynamicSelect && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
disabled={readOnly || isLoadingOptions}
defaultValue={varInput?.value}
items={(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
icon: option.icon,
}))}
defaultValue={varInput?.value as string | undefined}
items={dynamicSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
renderOption={({ item }) => (
@ -475,83 +363,22 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isDynamicSelect && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
<MultiSelectField
disabled={readOnly || isLoadingOptions}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('system-sm-regular block truncate text-left', isLoadingOptions
? 'text-components-input-text-placeholder'
: varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder')}
>
{isLoadingOptions
? 'Loading...'
: getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoadingOptions
? (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
)
: (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{option.label[language] || option.label.en_US}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
isLoading={isLoadingOptions}
value={(varInput?.value as string[] | undefined) || []}
items={dynamicSelectItems}
onChange={handleValueChange}
placeholder={placeholder?.[language] || placeholder?.en_US}
selectedLabel={selectedLabels}
/>
)}
{isShowJSONEditor && isConstant && (
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={varInput?.value as any}
isExpand
isInNode
language={CodeLanguage.json}
onChange={handleValueChange}
className="w-full"
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
</div>
<JsonEditorField
value={(varInput?.value as string) || ''}
onChange={handleValueChange}
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
)}
{isAppSelector && (
<AppSelector
@ -581,9 +408,9 @@ const FormInputItem: FC<Props> = ({
nodeId={nodeId}
value={varInput?.value || []}
onChange={value => handleVariableSelectorChange(value, variable)}
filterVar={getFilterVar()}
filterVar={getFilterVar(formState)}
schema={schema}
valueTypePlaceHolder={targetVarType()}
valueTypePlaceHolder={getTargetVarType(formState)}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isBoolean}

View File

@ -0,0 +1,226 @@
import type { ComponentProps } from 'react'
import type { FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
import VarReferencePicker from '../var-reference-picker'
const {
mockFetchDynamicOptions,
} = vi.hoisted(() => ({
mockFetchDynamicOptions: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFetchDynamicOptions: () => ({
mutateAsync: mockFetchDynamicOptions,
}),
}))
vi.mock('../var-reference-popup', () => ({
default: ({
onChange,
}: {
onChange: (value: string[], item: { variable: string, type: VarType }) => void
}) => (
<div>
<button onClick={() => onChange(['node-a', 'answer'], { variable: 'answer', type: VarType.string })}>select-normal</button>
<button onClick={() => onChange(['node-a', 'sys.query'], { variable: 'sys.query', type: VarType.string })}>select-system</button>
</div>
),
}))
describe('VarReferencePicker branches', () => {
const startNode = createStartNode({
id: 'start-node',
data: {
title: 'Start',
variables: [{
variable: 'query',
label: 'Query',
type: InputVarType.textInput,
required: false,
}],
},
})
const sourceNode = createNode({
id: 'node-a',
width: 120,
height: 60,
position: { x: 120, y: 80 },
data: {
type: BlockEnum.Code,
title: 'Source Node',
outputs: {
answer: { type: VarType.string },
},
},
})
const currentNode = createNode({
id: 'node-current',
data: { type: BlockEnum.Code, title: 'Current Node' },
})
const availableVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [
{ variable: 'answer', type: VarType.string },
],
}]
const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => {
const onChange = vi.fn()
const onOpen = vi.fn()
const result = renderWorkflowFlowComponent(
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<VarReferencePicker
nodeId="node-current"
readonly={false}
value={[]}
onChange={onChange}
onOpen={onOpen}
availableNodes={[startNode, sourceNode, currentNode]}
availableVars={availableVars}
{...props}
/>
</div>,
{
nodes: [startNode, sourceNode, currentNode],
edges: [],
hooksStoreProps: {},
},
)
return { ...result, onChange, onOpen }
}
beforeEach(() => {
resetFixtureCounters()
vi.clearAllMocks()
mockFetchDynamicOptions.mockResolvedValue({ options: [] as FormOption[] })
})
it('should toggle a custom trigger and call onOpen when opening the popup', async () => {
const { onOpen } = renderPicker({
trigger: <button>custom-trigger</button>,
})
fireEvent.click(screen.getByText('custom-trigger'))
expect(await screen.findByText('select-normal')).toBeInTheDocument()
await waitFor(() => {
expect(onOpen).toHaveBeenCalled()
})
})
it('should rewrite system selectors before forwarding the selection', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('select-system'))
expect(onChange).toHaveBeenCalledWith(
['sys', 'query'],
VarKindType.constant,
expect.objectContaining({
variable: 'sys.query',
type: VarType.string,
}),
)
})
it('should clear variable-mode values to an empty selector array', () => {
const { onChange } = renderPicker({
defaultVarKindType: VarKindType.variable,
isSupportConstantValue: true,
value: ['node-a', 'answer'],
})
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(onChange).toHaveBeenCalledWith([], VarKindType.variable)
})
it('should jump to the selected node when ctrl-clicking the node name', () => {
const { onChange } = renderPicker({
value: ['node-a', 'answer'],
})
fireEvent.click(screen.getByText('Source Node'), { ctrlKey: true })
expect(onChange).not.toHaveBeenCalled()
})
it('should fetch dynamic options for supported constant fields', async () => {
mockFetchDynamicOptions.mockResolvedValueOnce({
options: [{
value: 'dyn-1',
label: { en_US: 'Dynamic 1', zh_Hans: '动态 1' },
show_on: [],
}],
})
renderPicker({
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
isSupportConstantValue: true,
schema: {
variable: 'field',
type: 'dynamic-select',
} as never,
value: 'dyn-1',
})
await waitFor(() => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
})
it('should focus the hidden control input for supported constant values', async () => {
const { container } = renderPicker({
isSupportConstantValue: true,
schema: {
type: 'text-input',
} as never,
value: 'constant-value',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
const hiddenInput = container.querySelector('input.sr-only') as HTMLInputElement
await waitFor(() => {
expect(document.activeElement).toBe(hiddenInput)
})
})
it('should render tooltip branches for partial paths and invalid variables without changing behavior', () => {
const objectVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child', type: VarType.string }],
}],
}]
const { unmount } = renderPicker({
availableVars: objectVars,
value: ['node-a', 'payload', 'child'],
})
expect(screen.getByText('child')).toBeInTheDocument()
unmount()
renderPicker({
value: ['missing-node', 'answer'],
})
expect(screen.getByText('answer')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,236 @@
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ValueSelector } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { createLoopNode, createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
getDynamicSelectSchema,
getHasValue,
getIsIterationVar,
getIsLoopVar,
getOutputVarNode,
getOutputVarNodeId,
getTooltipContent,
getVarDisplayName,
getVariableCategory,
getVariableMeta,
getWidthAllocations,
isShowAPartSelector,
} from '../var-reference-picker.helpers'
describe('var-reference-picker.helpers', () => {
it('should detect whether the picker has a variable value', () => {
expect(getHasValue(false, ['node-1', 'answer'])).toBe(true)
expect(getHasValue(true, 'constant')).toBe(false)
expect(getHasValue(false, [])).toBe(false)
})
it('should detect iteration and loop variables by parent node id', () => {
expect(getIsIterationVar(true, ['iter-parent', 'item'], 'iter-parent')).toBe(true)
expect(getIsIterationVar(true, ['iter-parent', 'value'], 'iter-parent')).toBe(false)
expect(getIsLoopVar(true, ['loop-parent', 'index'], 'loop-parent')).toBe(true)
expect(getIsLoopVar(false, ['loop-parent', 'item'], 'loop-parent')).toBe(false)
})
it('should resolve output variable nodes for normal, system, iteration, and loop variables', () => {
const startNode = createStartNode({ id: 'start-1', data: { title: 'Start Node' } })
const normalNode = createNode({ id: 'node-a', data: { type: BlockEnum.Code, title: 'Answer Node' } })
const iterationNode = createNode({ id: 'iter-parent', data: { type: BlockEnum.Iteration, title: 'Iteration Parent' } })
const loopNode = createLoopNode({ id: 'loop-parent', data: { title: 'Loop Parent' } })
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'node-a',
startNode,
value: ['node-a', 'answer'],
})).toMatchObject({ id: 'node-a', title: 'Answer Node' })
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'sys',
startNode,
value: ['sys', 'files'],
})).toEqual(startNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: true,
isLoopVar: false,
iterationNode,
loopNode: null,
outputVarNodeId: 'iter-parent',
startNode,
value: ['iter-parent', 'item'],
})).toEqual(iterationNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: true,
iterationNode: null,
loopNode,
outputVarNodeId: 'loop-parent',
startNode,
value: ['loop-parent', 'item'],
})).toEqual(loopNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'missing-node',
startNode,
value: ['missing-node', 'answer'],
})).toBeNull()
})
it('should format display names and output node ids correctly', () => {
expect(getOutputVarNodeId(true, ['node-a', 'answer'])).toBe('node-a')
expect(getOutputVarNodeId(false, [])).toBe('')
expect(getVarDisplayName(true, ['sys', 'query'])).toBe('query')
expect(getVarDisplayName(true, ['node-a', 'answer'])).toBe('answer')
expect(getVarDisplayName(false, [])).toBe('')
})
it('should derive variable meta and category from selectors', () => {
const meta = getVariableMeta({ type: BlockEnum.Code }, ['env', 'API_KEY'], 'API_KEY')
expect(meta).toMatchObject({
isEnv: true,
isValidVar: true,
isException: true,
})
expect(getVariableCategory({
isChatVar: true,
isEnv: false,
isGlobal: false,
isLoopVar: false,
isRagVar: false,
})).toBe('conversation')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: true,
isLoopVar: false,
isRagVar: false,
})).toBe('global')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: false,
isLoopVar: true,
isRagVar: false,
})).toBe('loop')
expect(getVariableCategory({
isChatVar: false,
isEnv: true,
isGlobal: false,
isLoopVar: false,
isRagVar: false,
})).toBe('environment')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: false,
isLoopVar: false,
isRagVar: true,
})).toBe('rag')
})
it('should calculate width allocations and tooltip behavior', () => {
expect(getWidthAllocations(240, 'Node', 'answer', 'string')).toEqual({
maxNodeNameWidth: expect.any(Number),
maxTypeWidth: expect.any(Number),
maxVarNameWidth: expect.any(Number),
})
expect(getTooltipContent(true, true, true)).toBe('full-path')
expect(getTooltipContent(true, false, false)).toBe('invalid-variable')
expect(getTooltipContent(false, false, true)).toBeNull()
})
it('should produce dynamic select schemas and detect partial selectors', () => {
const value = 'selected'
const schema: Partial<CredentialFormSchema> = {
type: 'dynamic-select',
} as Partial<CredentialFormSchema>
expect(getDynamicSelectSchema({
dynamicOptions: [{
value: 'a',
label: { en_US: 'A', zh_Hans: 'A' },
show_on: [],
}],
isLoading: false,
schema,
value,
})).toMatchObject({
options: [{ value: 'a' }],
})
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: true,
schema,
value,
})).toMatchObject({
options: [{ value: 'selected' }],
})
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: false,
schema,
value,
})).toMatchObject({ options: [] })
expect(isShowAPartSelector(['node-a', 'payload', 'child'] as ValueSelector)).toBe(true)
expect(isShowAPartSelector(['rag', 'node-a', 'payload'] as ValueSelector)).toBe(false)
})
it('should keep mapped variable names for known workflow aliases', () => {
expect(getVarDisplayName(true, ['sys', 'files'])).toBe('files')
expect(getVariableMeta({ type: VarType.string }, ['conversation', 'name'], 'name')).toMatchObject({
isChatVar: true,
isValidVar: true,
})
})
it('should preserve non-dynamic schemas', () => {
const schema: Partial<CredentialFormSchema> = {
type: FormTypeEnum.textInput,
}
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: false,
schema,
value: '',
})).toEqual(schema)
})
})

View File

@ -0,0 +1,140 @@
import type { ComponentProps } from 'react'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import VarReferencePicker from '../var-reference-picker'
describe('VarReferencePicker', () => {
const startNode = createStartNode({
id: 'start-node',
data: {
title: 'Start',
variables: [{
variable: 'query',
label: 'Query',
type: InputVarType.textInput,
required: false,
}],
},
})
const sourceNode = createNode({
id: 'node-a',
data: {
type: BlockEnum.Code,
title: 'Source Node',
outputs: {
answer: { type: VarType.string },
payload: { type: VarType.object },
},
},
})
const currentNode = createNode({
id: 'node-current',
data: { type: BlockEnum.Code, title: 'Current Node' },
})
const availableVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [
{ variable: 'answer', type: VarType.string },
{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child', type: VarType.string }],
},
],
}]
const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => {
const onChange = vi.fn()
const result = renderWorkflowFlowComponent(
<div id="workflow-container">
<VarReferencePicker
nodeId="node-current"
readonly={false}
value={[]}
onChange={onChange}
availableNodes={[startNode, sourceNode, currentNode]}
availableVars={availableVars}
{...props}
/>
</div>,
{
nodes: [startNode, sourceNode, currentNode],
edges: [],
hooksStoreProps: {},
},
)
return { ...result, onChange }
}
beforeEach(() => {
resetFixtureCounters()
})
it('should open the popup and select a variable from the available list', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('answer'))
expect(onChange).toHaveBeenCalledWith(
['node-a', 'answer'],
'constant',
expect.objectContaining({
variable: 'answer',
type: VarType.string,
}),
)
})
it('should render the selected node and variable name, then clear it', async () => {
const { onChange } = renderPicker({
value: ['node-a', 'answer'],
})
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(onChange).toHaveBeenCalledWith('', 'constant')
})
it('should show object variables in the popup and select the root object path', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('payload'))
expect(onChange).toHaveBeenCalledWith(
['node-a', 'payload'],
'constant',
expect.objectContaining({
variable: 'payload',
type: VarType.object,
}),
)
})
it('should render a placeholder and respect readonly mode', async () => {
const { onChange } = renderPicker({
readonly: true,
placeholder: 'Pick a variable',
})
expect(screen.getByText('Pick a variable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
await waitFor(() => {
expect(screen.queryByText('answer')).not.toBeInTheDocument()
})
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,176 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
import VarReferencePickerTrigger from '../var-reference-picker.trigger'
const createProps = (
overrides: Partial<ComponentProps<typeof VarReferencePickerTrigger>> = {},
): ComponentProps<typeof VarReferencePickerTrigger> => ({
controlFocus: 0,
handleClearVar: vi.fn(),
handleVarKindTypeChange: vi.fn(),
handleVariableJump: vi.fn(),
hasValue: false,
inputRef: { current: null },
isConstant: false,
isException: false,
isFocus: false,
isLoading: false,
isShowAPart: false,
isShowNodeName: true,
maxNodeNameWidth: 80,
maxTypeWidth: 60,
maxVarNameWidth: 80,
onChange: vi.fn(),
open: false,
outputVarNode: null,
readonly: false,
setControlFocus: vi.fn(),
setOpen: vi.fn(),
tooltipPopup: null,
triggerRef: { current: null },
value: [],
varKindType: VarKindType.constant,
varKindTypes: [
{ label: 'Variable', value: VarKindType.variable },
{ label: 'Constant', value: VarKindType.constant },
],
varName: '',
variableCategory: 'system',
WrapElem: 'div',
VarPickerWrap: 'div',
...overrides,
})
describe('VarReferencePickerTrigger', () => {
it('should show the placeholder state and open the picker for variable mode', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Pick variable',
setOpen,
})}
/>,
)
expect(screen.getByText('Pick variable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should render the selected variable state and clear it', () => {
const handleClearVar = vi.fn()
const handleVariableJump = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
fireEvent.click(screen.getByText('Source Node'), { ctrlKey: true })
expect(handleVariableJump).toHaveBeenCalledWith('node-a')
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(handleClearVar).toHaveBeenCalledTimes(1)
})
it('should render the support-constant trigger and focus constant input when clicked', () => {
const setControlFocus = vi.fn()
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})}
/>,
)
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setControlFocus).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('Constant'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should render add button trigger in table mode', () => {
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should stay inert in readonly mode and show value type placeholder badge', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Readonly placeholder',
readonly: true,
setOpen,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})}
/>,
)
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getByText('text')).toBeInTheDocument()
})
it('should show loading placeholder and remove rows in table mode', () => {
const onRemove = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})}
/>,
)
expect(screen.getByText('Loading variable')).toBeInTheDocument()
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[buttons.length - 1])
expect(onRemove).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,221 @@
'use client'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType, Node, ValueSelector } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from './utils'
type DynamicSchemaParams = {
dynamicOptions: FormOption[] | null
isLoading: boolean
schema?: Partial<CredentialFormSchema>
value: ValueSelector | string
}
type VariableCategoryParams = {
isChatVar: boolean
isEnv: boolean
isGlobal: boolean
isLoopVar: boolean
isRagVar: boolean
}
type OutputVarNodeParams = {
availableNodes: Node[]
hasValue: boolean
isConstant: boolean
isIterationVar: boolean
isLoopVar: boolean
iterationNode: Node<CommonNodeType> | null
loopNode: Node<CommonNodeType> | null
outputVarNodeId: string
startNode?: Node | null
value: ValueSelector | string
}
export const getVarKindOptions = (variableLabel = 'Variable', constantLabel = 'Constant') => ([
{ label: variableLabel, value: 'variable' as VarKindType },
{ label: constantLabel, value: 'constant' as VarKindType },
])
export const getHasValue = (isConstant: boolean, value: ValueSelector | string) =>
!isConstant && value.length > 0
export const getIsIterationVar = (
isInIteration: boolean,
value: ValueSelector | string,
parentId?: string,
) => {
if (!isInIteration || !Array.isArray(value))
return false
return value[0] === parentId && ['item', 'index'].includes(value[1])
}
export const getIsLoopVar = (
isInLoop: boolean,
value: ValueSelector | string,
parentId?: string,
) => {
if (!isInLoop || !Array.isArray(value))
return false
return value[0] === parentId && ['item', 'index'].includes(value[1])
}
export const getOutputVarNode = ({
availableNodes,
hasValue,
isConstant,
isIterationVar,
isLoopVar,
iterationNode,
loopNode,
outputVarNodeId,
startNode,
value,
}: OutputVarNodeParams) => {
if (!hasValue || isConstant)
return null
if (isIterationVar)
return iterationNode?.data ?? null
if (isLoopVar)
return loopNode?.data ?? null
if (isSystemVar(value as ValueSelector))
return startNode?.data ?? null
const node = getNodeInfoById(availableNodes, outputVarNodeId)?.data
if (!node)
return null
return {
...node,
id: outputVarNodeId,
}
}
export const getVarDisplayName = (
hasValue: boolean,
value: ValueSelector | string,
) => {
if (!hasValue || !Array.isArray(value))
return ''
const showName = VAR_SHOW_NAME_MAP[value.join('.')]
if (showName)
return showName
const isSystem = isSystemVar(value)
const varName = value[value.length - 1] ?? ''
return `${isSystem ? 'sys.' : ''}${varName}`
}
export const getVariableMeta = (
outputVarNode: { type?: string } | null,
value: ValueSelector | string,
varName: string,
) => {
const selector = value as ValueSelector
const isEnv = isENV(selector)
const isChatVar = isConversationVar(selector)
const isGlobal = isGlobalVar(selector)
const isRagVar = isRagVariableVar(selector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar
return {
isChatVar,
isEnv,
isGlobal,
isRagVar,
isValidVar,
isException: Boolean(varName && outputVarNode?.type),
}
}
export const getVariableCategory = ({
isChatVar,
isEnv,
isGlobal,
isLoopVar,
isRagVar,
}: VariableCategoryParams) => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isGlobal)
return 'global'
if (isLoopVar)
return 'loop'
if (isRagVar)
return 'rag'
return 'system'
}
export const getWidthAllocations = (
triggerWidth: number,
nodeTitle: string,
varName: string,
type: string,
) => {
const availableWidth = triggerWidth - 56
const totalTextLength = (nodeTitle + varName + type).length || 1
const priorityWidth = 15
return {
maxNodeNameWidth: priorityWidth + Math.floor(nodeTitle.length / totalTextLength * availableWidth),
maxTypeWidth: Math.floor(type.length / totalTextLength * availableWidth),
maxVarNameWidth: -priorityWidth + Math.floor(varName.length / totalTextLength * availableWidth),
}
}
export const getDynamicSelectSchema = ({
dynamicOptions,
isLoading,
schema,
value,
}: DynamicSchemaParams) => {
if (schema?.type !== 'dynamic-select')
return schema
if (dynamicOptions) {
return {
...schema,
options: dynamicOptions,
}
}
if (isLoading && value && typeof value === 'string') {
return {
...schema,
options: [{
value,
label: { en_US: value, zh_Hans: value },
show_on: [],
}],
}
}
return {
...schema,
options: [],
}
}
export const getTooltipContent = (
hasValue: boolean,
isShowAPart: boolean,
isValidVar: boolean,
) => {
if (isValidVar && isShowAPart)
return 'full-path'
if (!isValidVar && hasValue)
return 'invalid-variable'
return null
}
export const getOutputVarNodeId = (hasValue: boolean, value: ValueSelector | string) =>
hasValue && Array.isArray(value) ? value[0] : ''
export const isShowAPartSelector = (value: ValueSelector | string) =>
Array.isArray(value) && value.length > 2 && !isRagVariableVar(value)

View File

@ -0,0 +1,315 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
import Badge from '@/app/components/base/badge'
import AddButton from '@/app/components/base/button/add-button'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { cn } from '@/utils/classnames'
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
type Props = {
className?: string
controlFocus: number
currentProvider?: ToolWithProvider | TriggerWithProvider
currentTool?: Tool
handleClearVar: () => void
handleVarKindTypeChange: (value: VarKindType) => void
handleVariableJump: (nodeId: string) => void
hasValue: boolean
inputRef: React.RefObject<HTMLInputElement | null>
inTable?: boolean
isAddBtnTrigger?: boolean
isConstant: boolean
isException: boolean
isFocus: boolean
isInTable?: boolean
isJustShowValue?: boolean
isLoading: boolean
isShowAPart: boolean
isShowNodeName: boolean
isSupportConstantValue?: boolean
maxNodeNameWidth: number
maxTypeWidth: number
maxVarNameWidth: number
onChange: (value: ValueSelector | string, varKindType: VarKindType, varInfo?: Var) => void
onRemove?: () => void
open: boolean
outputVarNode?: Node['data'] | null
outputVarNodeId?: string
placeholder?: string
readonly: boolean
schemaWithDynamicSelect?: Partial<CredentialFormSchema>
setControlFocus: (value: number) => void
setOpen: (value: boolean) => void
tooltipPopup: ReactNode
triggerRef: React.RefObject<HTMLDivElement | null>
type?: string
typePlaceHolder?: string
value: ValueSelector | string
valueTypePlaceHolder?: string
varKindType: VarKindType
varKindTypes: Array<{ label: string, value: VarKindType }>
varName: string
variableCategory: string
WrapElem: React.ElementType
VarPickerWrap: React.ElementType
}
const VarReferencePickerTrigger: FC<Props> = ({
className,
controlFocus,
handleClearVar,
handleVarKindTypeChange,
handleVariableJump,
hasValue,
inputRef,
isAddBtnTrigger,
isConstant,
isException,
isFocus,
isInTable,
isJustShowValue,
isLoading,
isShowAPart,
isShowNodeName,
isSupportConstantValue,
maxNodeNameWidth,
maxTypeWidth,
maxVarNameWidth,
onChange,
onRemove,
open,
outputVarNode,
outputVarNodeId,
placeholder,
readonly,
schemaWithDynamicSelect,
setControlFocus,
setOpen,
tooltipPopup,
triggerRef,
type,
typePlaceHolder,
value,
valueTypePlaceHolder,
varKindType,
varKindTypes,
varName,
variableCategory,
VarPickerWrap,
WrapElem,
}) => {
return (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className={cn(className, 'group/picker-trigger-wrap relative !flex', !readonly && 'cursor-pointer')}
data-testid="var-reference-picker-trigger"
>
<>
{isAddBtnTrigger
? (
<div>
<AddButton onClick={() => {}}></AddButton>
</div>
)
: (
<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'group/wrap relative flex h-8 w-full items-center', !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', isInTable && 'border-none bg-transparent', readonly && 'bg-components-input-bg-disabled', isJustShowValue && 'h-6 bg-transparent p-0')}>
{isSupportConstantValue
? (
<div
onClick={(e) => {
e.stopPropagation()
setOpen(false)
setControlFocus(Date.now())
}}
className="mr-1 flex h-full items-center space-x-1"
>
<TypeSelector
noLeft
trigger={(
<div className="flex h-8 items-center bg-components-input-bg-normal px-2 radius-md">
<div className="mr-1 text-components-input-text-filled system-sm-regular">{varKindTypes.find(item => item.value === varKindType)?.label}</div>
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
)}
popupClassName="top-8"
readonly={readonly}
value={varKindType}
options={varKindTypes}
onChange={handleVarKindTypeChange}
showChecked
/>
</div>
)
: (!hasValue && (
<div className="ml-1.5 mr-1">
<Variable02 className={`h-4 w-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} />
</div>
))}
{isConstant
? (
<ConstantField
value={value as string}
onChange={onChange as ((value: string | number, varKindType: VarKindType, varInfo?: Var) => void)}
schema={schemaWithDynamicSelect as CredentialFormSchemaSelect}
readonly={readonly}
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip>
<TooltipTrigger
disabled={!tooltipPopup}
render={(
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-[1px]">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<div className="h-3 w-3" />
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isException && 'text-text-warning')}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center capitalize text-text-tertiary system-xs-regular"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{!('title' in (outputVarNode || {})) && <RiErrorWarningFill className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} text-ellipsis system-sm-regular`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)}
/>
{tooltipPopup && (
<TooltipContent variant="plain">
{tooltipPopup}
</TooltipContent>
)}
</Tooltip>
</div>
</VarPickerWrap>
)}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute right-1 top-[50%] h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 hover:bg-state-base-hover group-hover/wrap:visible"
onClick={handleClearVar}
data-testid="var-reference-picker-clear"
>
<RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" />
</div>
)}
{!hasValue && valueTypePlaceHolder && (
<Badge
className="absolute right-1 top-[50%] translate-y-[-50%] capitalize"
text={valueTypePlaceHolder}
uppercase={false}
/>
)}
</div>
)}
{!readonly && isInTable && (
<RemoveButton
className="absolute right-1 top-0.5 hidden group-hover/picker-trigger-wrap:block"
onClick={() => onRemove?.()}
/>
)}
{!hasValue && typePlaceHolder && (
<Badge
className="absolute right-2 top-1.5"
text={typePlaceHolder}
uppercase={false}
/>
)}
</>
<input ref={inputRef} className="sr-only" value={controlFocus} readOnly />
</WrapElem>
)
}
export default VarReferencePickerTrigger

View File

@ -4,13 +4,6 @@ import type { CredentialFormSchema, CredentialFormSchemaSelect, FormOption } fro
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import {
RiArrowDownSLine,
RiCloseLine,
RiErrorWarningFill,
RiLoader4Line,
RiMoreLine,
} from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
@ -21,36 +14,41 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import Badge from '@/app/components/base/badge'
import AddButton from '@/app/components/base/button/add-button'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import {
useIsChatMode,
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
// import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import { useFetchDynamicOptions } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import useAvailableVarList from '../../hooks/use-available-var-list'
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils'
import { removeFileVars, varTypeToStructType } from './utils'
import VarFullPathPanel from './var-full-path-panel'
import {
getDynamicSelectSchema,
getHasValue,
getIsIterationVar,
getIsLoopVar,
getOutputVarNode,
getOutputVarNodeId,
getTooltipContent,
getVarDisplayName,
getVariableCategory,
getVariableMeta,
getVarKindOptions,
getWidthAllocations,
isShowAPartSelector,
} from './var-reference-picker.helpers'
import VarReferencePickerTrigger from './var-reference-picker.trigger'
import VarReferencePopup from './var-reference-popup'
const TRIGGER_DEFAULT_WIDTH = 227
@ -141,17 +139,17 @@ const VarReferencePicker: FC<Props> = ({
const node = nodes.find(n => n.id === nodeId)
const isInIteration = !!(node?.data as any)?.isInIteration
const iterationNode = isInIteration ? nodes.find(n => n.id === node?.parentId) : null
const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const isInLoop = !!(node?.data as any)?.isInLoop
const loopNode = isInLoop ? nodes.find(n => n.id === node?.parentId) : null
const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
if (triggerRef.current)
setTriggerWidth(triggerRef.current.clientWidth)
}, [triggerRef.current])
}, [])
const [varKindType, setVarKindType] = useState<VarKindType>(defaultVarKindType)
const isConstant = isSupportConstantValue && varKindType === VarKindType.constant
@ -164,72 +162,41 @@ const VarReferencePicker: FC<Props> = ({
const [open, setOpen] = useState(false)
useEffect(() => {
onOpen()
}, [open])
const hasValue = !isConstant && value.length > 0
}, [open, onOpen])
const hasValue = getHasValue(!!isConstant, value)
const isIterationVar = useMemo(() => {
if (!isInIteration)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInIteration, value, node])
const isIterationVar = useMemo(
() => getIsIterationVar(isInIteration, value, node?.parentId),
[isInIteration, node?.parentId, value],
)
const isLoopVar = useMemo(() => {
if (!isInLoop)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInLoop, value, node])
const isLoopVar = useMemo(
() => getIsLoopVar(isInLoop, value, node?.parentId),
[isInLoop, node?.parentId, value],
)
const outputVarNodeId = hasValue ? value[0] : ''
const outputVarNode = useMemo(() => {
if (!hasValue || isConstant)
return null
const outputVarNodeId = getOutputVarNodeId(hasValue, value)
const outputVarNode = useMemo(() => getOutputVarNode({
availableNodes,
hasValue,
isConstant: !!isConstant,
isIterationVar,
isLoopVar,
iterationNode,
loopNode,
outputVarNodeId,
startNode,
value,
}), [availableNodes, hasValue, isConstant, isIterationVar, isLoopVar, iterationNode, loopNode, outputVarNodeId, startNode, value])
if (isIterationVar)
return iterationNode?.data
const isShowAPart = isShowAPartSelector(value)
if (isLoopVar)
return loopNode?.data
const varName = useMemo(
() => getVarDisplayName(hasValue, value),
[hasValue, value],
)
if (isSystemVar(value as ValueSelector))
return startNode?.data
const node = getNodeInfoById(availableNodes, outputVarNodeId)?.data
if (node) {
return {
...node,
id: outputVarNodeId,
}
}
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const isShowAPart = (value as ValueSelector).length > 2 && !isRagVariableVar((value as ValueSelector))
const varName = useMemo(() => {
if (!hasValue)
return ''
const showName = VAR_SHOW_NAME_MAP[(value as ValueSelector).join('.')]
if (showName)
return showName
const isSystem = isSystemVar(value as ValueSelector)
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value])
const varKindTypes = [
{
label: 'Variable',
value: VarKindType.variable,
},
{
label: 'Constant',
value: VarKindType.constant,
},
]
const varKindTypes = getVarKindOptions()
const handleVarKindTypeChange = useCallback((value: VarKindType) => {
setVarKindType(value)
@ -302,39 +269,28 @@ const VarReferencePicker: FC<Props> = ({
preferSchemaType,
})
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar, isException } = useMemo(() => {
const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector)
const isGlobal = isGlobalVar(value as ValueSelector)
const isRagVar = isRagVariableVar(value as ValueSelector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar
const isException = isExceptionVariable(varName, outputVarNode?.type)
return {
isEnv,
isChatVar,
isGlobal,
isRagVar,
isValidVar,
isException,
}
}, [value, outputVarNode, varName])
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar } = useMemo(
() => getVariableMeta(outputVarNode, value, varName),
[outputVarNode, value, varName],
)
const isException = useMemo(
() => isExceptionVariable(varName, outputVarNode?.type),
[outputVarNode?.type, varName],
)
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56
const [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth] = (() => {
const totalTextLength = ((outputVarNode?.title || '') + (varName || '') + (type || '')).length
const PRIORITY_WIDTH = 15
const maxNodeNameWidth = PRIORITY_WIDTH + Math.floor((outputVarNode?.title?.length || 0) / totalTextLength * availableWidth)
const maxVarNameWidth = -PRIORITY_WIDTH + Math.floor((varName?.length || 0) / totalTextLength * availableWidth)
const maxTypeWidth = Math.floor((type?.length || 0) / totalTextLength * availableWidth)
return [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth]
})()
const {
maxNodeNameWidth,
maxTypeWidth,
maxVarNameWidth,
} = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '')
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
if (isValidVar && isShowAPart) {
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
if (tooltipType === 'full-path') {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
@ -344,7 +300,7 @@ const VarReferencePicker: FC<Props> = ({
/>
)
}
if (!isValidVar && hasValue)
if (tooltipType === 'invalid-variable')
return t('errorMsg.invalidVariable', { ns: 'workflow' })
return null
@ -359,7 +315,7 @@ const VarReferencePicker: FC<Props> = ({
(schema as CredentialFormSchemaSelect)?.variable || '',
'tool',
)
const handleFetchDynamicOptions = async () => {
const handleFetchDynamicOptions = useCallback(async () => {
if (schema?.type !== FormTypeEnum.dynamicSelect || !currentTool || !currentProvider)
return
setIsLoading(true)
@ -370,58 +326,25 @@ const VarReferencePicker: FC<Props> = ({
finally {
setIsLoading(false)
}
}
}, [currentProvider, currentTool, fetchDynamicOptions, schema?.type])
useEffect(() => {
handleFetchDynamicOptions()
}, [currentTool, currentProvider, schema])
}, [handleFetchDynamicOptions])
const schemaWithDynamicSelect = useMemo(() => {
if (schema?.type !== FormTypeEnum.dynamicSelect)
return schema
// rewrite schema.options with dynamicOptions
if (dynamicOptions) {
return {
...schema,
options: dynamicOptions,
}
}
const schemaWithDynamicSelect = useMemo(
() => getDynamicSelectSchema({ dynamicOptions, isLoading, schema, value }),
[dynamicOptions, isLoading, schema, value],
)
// If we don't have dynamic options but we have a selected value, create a temporary option to preserve the selection during loading
if (isLoading && value && typeof value === 'string') {
const preservedOptions = [{
value,
label: { en_US: value, zh_Hans: value },
show_on: [],
}]
return {
...schema,
options: preservedOptions,
}
}
const variableCategory = useMemo(
() => getVariableCategory({ isChatVar, isEnv, isGlobal, isLoopVar, isRagVar }),
[isChatVar, isEnv, isGlobal, isLoopVar, isRagVar],
)
// Default case: return schema with empty options
return {
...schema,
options: [],
}
}, [schema, dynamicOptions, isLoading, value])
const variableCategory = useMemo(() => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isGlobal)
return 'global'
if (isLoopVar)
return 'loop'
if (isRagVar)
return 'rag'
return 'system'
}, [isEnv, isChatVar, isGlobal, isLoopVar, isRagVar])
const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
return (
<div className={cn(className, !readonly && 'cursor-pointer')}>
<div className={cn(className)}>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -429,204 +352,52 @@ const VarReferencePicker: FC<Props> = ({
>
{!!trigger && <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>{trigger}</PortalToFollowElemTrigger>}
{!trigger && (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="group/picker-trigger-wrap relative !flex"
>
<>
{isAddBtnTrigger
? (
<div>
<AddButton onClick={noop}></AddButton>
</div>
)
: (
<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'group/wrap relative flex h-8 w-full items-center', !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', isInTable && 'border-none bg-transparent', readonly && 'bg-components-input-bg-disabled', isJustShowValue && 'h-6 bg-transparent p-0')}>
{isSupportConstantValue
? (
<div
onClick={(e) => {
e.stopPropagation()
setOpen(false)
setControlFocus(Date.now())
}}
className="mr-1 flex h-full items-center space-x-1"
>
<TypeSelector
noLeft
trigger={(
<div className="radius-md flex h-8 items-center bg-components-input-bg-normal px-2">
<div className="system-sm-regular mr-1 text-components-input-text-filled">{varKindTypes.find(item => item.value === varKindType)?.label}</div>
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
)}
popupClassName="top-8"
readonly={readonly}
value={varKindType}
options={varKindTypes}
onChange={handleVarKindTypeChange}
showChecked
/>
</div>
)
: (!hasValue && (
<div className="ml-1.5 mr-1">
<Variable02 className={`h-4 w-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} />
</div>
))}
{isConstant
? (
<ConstantField
value={value as string}
onChange={onChange as ((value: string | number, varKindType: VarKindType, varInfo?: Var) => void)}
schema={schemaWithDynamicSelect as CredentialFormSchema}
readonly={readonly}
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip noDecoration={isShowAPart} popupContent={tooltipPopup}>
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
e.stopPropagation()
handleVariableJump(outputVarNode?.id)
}
}}
>
<div className="h-3 px-[1px]">
{outputVarNode?.type && (
<VarBlockIcon
className="!text-text-primary"
type={outputVarNode.type}
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title}
style={{
maxWidth: maxNodeNameWidth,
}}
>
{outputVarNode?.title}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning', isGlobal && 'text-util-colors-orange-orange-600')}
title={varName}
style={{
maxWidth: maxVarNameWidth,
}}
>
{varName}
</div>
</div>
<div
className="system-xs-regular ml-0.5 truncate text-center capitalize text-text-tertiary"
title={type}
style={{
maxWidth: maxTypeWidth,
}}
>
{type}
</div>
{!isValidVar && <RiErrorWarningFill className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} system-sm-regular text-ellipsis`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })}</span>
</div>
)
: (
placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
)}
</div>
)}
</div>
</Tooltip>
</div>
</VarPickerWrap>
)}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute right-1 top-[50%] h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 hover:bg-state-base-hover group-hover/wrap:visible"
onClick={handleClearVar}
>
<RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" />
</div>
)}
{!hasValue && valueTypePlaceHolder && (
<Badge
className=" absolute right-1 top-[50%] translate-y-[-50%] capitalize"
text={valueTypePlaceHolder}
uppercase={false}
/>
)}
</div>
)}
{!readonly && isInTable && (
<RemoveButton
className="absolute right-1 top-0.5 hidden group-hover/picker-trigger-wrap:block"
onClick={() => onRemove?.()}
/>
)}
{!hasValue && typePlaceHolder && (
<Badge
className="absolute right-2 top-1.5"
text={typePlaceHolder}
uppercase={false}
/>
)}
</>
</WrapElem>
<VarReferencePickerTrigger
className={className}
controlFocus={controlFocus}
currentProvider={currentProvider}
currentTool={currentTool}
handleClearVar={handleClearVar}
handleVarKindTypeChange={handleVarKindTypeChange}
handleVariableJump={handleVariableJump}
hasValue={hasValue}
inputRef={inputRef}
isAddBtnTrigger={isAddBtnTrigger}
isConstant={!!isConstant}
isException={isException}
isFocus={isFocus}
isInTable={isInTable}
isJustShowValue={isJustShowValue}
isLoading={isLoading}
isShowAPart={isShowAPart}
isShowNodeName={isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar}
isSupportConstantValue={isSupportConstantValue}
maxNodeNameWidth={maxNodeNameWidth}
maxTypeWidth={maxTypeWidth}
maxVarNameWidth={maxVarNameWidth}
onChange={onChange}
onRemove={onRemove}
open={open}
outputVarNode={outputVarNode as Node['data'] | null}
outputVarNodeId={outputVarNodeId}
placeholder={triggerPlaceholder}
readonly={readonly}
schemaWithDynamicSelect={schemaWithDynamicSelect}
setControlFocus={setControlFocus}
setOpen={setOpen}
tooltipPopup={tooltipPopup}
triggerRef={triggerRef}
type={type}
typePlaceHolder={typePlaceHolder}
value={value}
valueTypePlaceHolder={valueTypePlaceHolder}
varKindType={varKindType}
varKindTypes={varKindTypes}
varName={varName}
variableCategory={variableCategory}
VarPickerWrap={VarPickerWrap}
WrapElem={WrapElem}
/>
)}
<PortalToFollowElemContent
style={{

View File

@ -0,0 +1,242 @@
import type { ComponentType } from 'react'
import type { Node } from './types'
import {
RiAlignBottom,
RiAlignCenter,
RiAlignJustify,
RiAlignLeft,
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { produce } from 'immer'
export const AlignType = {
Bottom: 'bottom',
Center: 'center',
DistributeHorizontal: 'distributeHorizontal',
DistributeVertical: 'distributeVertical',
Left: 'left',
Middle: 'middle',
Right: 'right',
Top: 'top',
} as const
export type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
type SelectionMenuPosition = {
left: number
top: number
}
type ContainerRect = Pick<DOMRect, 'width' | 'height'>
type AlignBounds = {
minX: number
maxX: number
minY: number
maxY: number
}
type MenuItem = {
alignType: AlignTypeValue
icon: ComponentType<{ className?: string }>
iconClassName?: string
translationKey: string
}
export type MenuSection = {
titleKey: string
items: MenuItem[]
}
const MENU_WIDTH = 240
const MENU_HEIGHT = 380
export const MENU_SECTIONS: MenuSection[] = [
{
titleKey: 'operator.vertical',
items: [
{ alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: RiAlignCenter, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: RiAlignBottom, translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
],
},
{
titleKey: 'operator.horizontal',
items: [
{ alignType: AlignType.Left, icon: RiAlignLeft, translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: RiAlignCenter, translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: RiAlignRight, translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' },
],
},
]
export const getMenuPosition = (
selectionMenu: SelectionMenuPosition | undefined,
containerRect?: ContainerRect | null,
) => {
if (!selectionMenu)
return { left: 0, top: 0 }
let { left, top } = selectionMenu
if (containerRect) {
if (left + MENU_WIDTH > containerRect.width)
left = left - MENU_WIDTH
if (top + MENU_HEIGHT > containerRect.height)
top = top - MENU_HEIGHT
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
}
export const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const childNodeIds = new Set<string>()
nodes.forEach((node) => {
if (!node.data._children?.length || !selectedNodeIds.has(node.id))
return
node.data._children.forEach((child) => {
childNodeIds.add(child.nodeId)
})
})
return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
}
export const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
const validNodes = nodes.filter(node => node.width && node.height)
if (validNodes.length <= 1)
return null
return validNodes.reduce<AlignBounds>((bounds, node) => {
const width = node.width!
const height = node.height!
return {
minX: Math.min(bounds.minX, node.position.x),
maxX: Math.max(bounds.maxX, node.position.x + width),
minY: Math.min(bounds.minY, node.position.y),
maxY: Math.max(bounds.maxY, node.position.y + height),
}
}, {
minX: Number.MAX_SAFE_INTEGER,
maxX: Number.MIN_SAFE_INTEGER,
minY: Number.MAX_SAFE_INTEGER,
maxY: Number.MIN_SAFE_INTEGER,
})
}
export const alignNodePosition = (
currentNode: Node,
nodeToAlign: Node,
alignType: AlignTypeValue,
bounds: AlignBounds,
) => {
const width = nodeToAlign.width ?? 0
const height = nodeToAlign.height ?? 0
switch (alignType) {
case AlignType.Left:
currentNode.position.x = bounds.minX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = bounds.minX
break
case AlignType.Center: {
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
currentNode.position.x = centerX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = centerX
break
}
case AlignType.Right: {
const rightX = bounds.maxX - width
currentNode.position.x = rightX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = rightX
break
}
case AlignType.Top:
currentNode.position.y = bounds.minY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = bounds.minY
break
case AlignType.Middle: {
const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
currentNode.position.y = middleY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = middleY
break
}
case AlignType.Bottom: {
const bottomY = Math.round(bounds.maxY - height)
currentNode.position.y = bottomY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = bottomY
break
}
}
}
export const distributeNodes = (
nodesToAlign: Node[],
nodes: Node[],
alignType: AlignTypeValue,
) => {
const isHorizontal = alignType === AlignType.DistributeHorizontal
const sortedNodes = [...nodesToAlign].sort((a, b) =>
isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
if (sortedNodes.length < 3)
return null
const firstNode = sortedNodes[0]
const lastNode = sortedNodes[sortedNodes.length - 1]
const totalGap = isHorizontal
? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
: lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
const fixedSpace = sortedNodes.reduce((sum, node) =>
sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
if (spacing <= 0)
return null
return produce(nodes, (draft) => {
let currentPosition = isHorizontal
? firstNode.position.x + (firstNode.width || 0)
: firstNode.position.y + (firstNode.height || 0)
for (let index = 1; index < sortedNodes.length - 1; index++) {
const nodeToAlign = sortedNodes[index]
const currentNode = draft.find(node => node.id === nodeToAlign.id)
if (!currentNode)
continue
if (isHorizontal) {
const nextX = currentPosition + spacing
currentNode.position.x = nextX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = nextX
currentPosition = nextX + (nodeToAlign.width || 0)
}
else {
const nextY = currentPosition + spacing
currentNode.position.y = nextY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = nextY
currentPosition = nextY + (nodeToAlign.height || 0)
}
}
})
}

View File

@ -1,11 +1,4 @@
import {
RiAlignBottom,
RiAlignCenter,
RiAlignJustify,
RiAlignLeft,
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import type { AlignTypeValue } from './selection-contextmenu.helpers'
import { useClickAway } from 'ahooks'
import { produce } from 'immer'
import {
@ -20,19 +13,17 @@ import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import {
alignNodePosition,
AlignType,
distributeNodes,
getAlignableNodes,
getAlignBounds,
getMenuPosition,
MENU_SECTIONS,
} from './selection-contextmenu.helpers'
import { useStore, useWorkflowStore } from './store'
enum AlignType {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom',
DistributeHorizontal = 'distributeHorizontal',
DistributeVertical = 'distributeVertical',
}
const SelectionContextmenu = () => {
const { t } = useTranslation()
const ref = useRef(null)
@ -55,31 +46,8 @@ const SelectionContextmenu = () => {
const menuRef = useRef<HTMLDivElement>(null)
const menuPosition = useMemo(() => {
if (!selectionMenu)
return { left: 0, top: 0 }
let left = selectionMenu.left
let top = selectionMenu.top
const container = document.querySelector('#workflow-container')
if (container) {
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
const menuWidth = 240
const estimatedMenuHeight = 380
if (left + menuWidth > containerWidth)
left = left - menuWidth
if (top + estimatedMenuHeight > containerHeight)
top = top - estimatedMenuHeight
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
}, [selectionMenu])
useClickAway(() => {
@ -91,163 +59,7 @@ const SelectionContextmenu = () => {
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
// Handle align nodes logic
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
const width = nodeToAlign.width
const height = nodeToAlign.height
// Calculate new positions based on alignment type
switch (alignType) {
case AlignType.Left:
// For left alignment, align left edge of each node to minX
currentNode.position.x = minX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = minX
break
case AlignType.Center: {
// For center alignment, center each node horizontally in the selection bounds
const centerX = minX + (maxX - minX) / 2 - width / 2
currentNode.position.x = centerX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = centerX
break
}
case AlignType.Right: {
// For right alignment, align right edge of each node to maxX
const rightX = maxX - width
currentNode.position.x = rightX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = rightX
break
}
case AlignType.Top: {
// For top alignment, align top edge of each node to minY
currentNode.position.y = minY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = minY
break
}
case AlignType.Middle: {
// For middle alignment, center each node vertically in the selection bounds
const middleY = minY + (maxY - minY) / 2 - height / 2
currentNode.position.y = middleY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = middleY
break
}
case AlignType.Bottom: {
// For bottom alignment, align bottom edge of each node to maxY
const newY = Math.round(maxY - height)
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY
break
}
}
}, [])
// Handle distribute nodes logic
const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => {
// Sort nodes appropriately
const sortedNodes = [...nodesToAlign].sort((a, b) => {
if (alignType === AlignType.DistributeHorizontal) {
// Sort by left position for horizontal distribution
return a.position.x - b.position.x
}
else {
// Sort by top position for vertical distribution
return a.position.y - b.position.y
}
})
if (sortedNodes.length < 3)
return null // Need at least 3 nodes for distribution
let totalGap = 0
let fixedSpace = 0
if (alignType === AlignType.DistributeHorizontal) {
// Fixed positions - first node's left edge and last node's right edge
const firstNodeLeft = sortedNodes[0].position.x
const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0)
// Total available space
totalGap = lastNodeRight - firstNodeLeft
// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0)
}
else {
// Fixed positions - first node's top edge and last node's bottom edge
const firstNodeTop = sortedNodes[0].position.y
const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0)
// Total available space
totalGap = lastNodeBottom - firstNodeTop
// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0)
}
// Available space for gaps
const availableSpace = totalGap - fixedSpace
// Calculate even spacing between node edges
const spacing = availableSpace / (sortedNodes.length - 1)
if (spacing <= 0)
return null // Nodes are overlapping, can't distribute evenly
return produce(nodes, (draft) => {
// Keep first node fixed, position others with even gaps
let currentPosition
if (alignType === AlignType.DistributeHorizontal) {
// Start from first node's right edge
currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0)
}
else {
// Start from first node's bottom edge
currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0)
}
// Skip first node (index 0), it stays in place
for (let i = 1; i < sortedNodes.length - 1; i++) {
const nodeToAlign = sortedNodes[i]
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
continue
if (alignType === AlignType.DistributeHorizontal) {
// Position = previous right edge + spacing
const newX: number = currentPosition + spacing
currentNode.position.x = newX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = newX
// Update for next iteration - current node's right edge
currentPosition = newX + (nodeToAlign.width || 0)
}
else {
// Position = previous bottom edge + spacing
const newY: number = currentPosition + spacing
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY
// Update for next iteration - current node's bottom edge
currentPosition = newY + (nodeToAlign.height || 0)
}
}
})
}, [])
const handleAlignNodes = useCallback((alignType: AlignType) => {
const handleAlignNodes = useCallback((alignType: AlignTypeValue) => {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
handleSelectionContextmenuCancel()
return
@ -258,116 +70,60 @@ const SelectionContextmenu = () => {
// Get all current nodes
const nodes = store.getState().getNodes()
// Get all selected nodes
const selectedNodeIds = selectedNodes.map(node => node.id)
// Find container nodes and their children
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
// when the container is selected. This prevents child nodes from being moved outside their containers.
const childNodeIds = new Set<string>()
nodes.forEach((node) => {
// Check if this is a container node (Iteration or Loop)
if (node.data._children && node.data._children.length > 0) {
// If container node is selected, add its children to the exclusion set
if (selectedNodeIds.includes(node.id)) {
// Add all its children to the childNodeIds set
node.data._children.forEach((child: { nodeId: string, nodeType: string }) => {
childNodeIds.add(child.nodeId)
})
}
}
})
// Filter out child nodes from the alignment operation
// Only align nodes that are selected AND are not children of container nodes
// This ensures container nodes can be aligned while their children stay in the same relative position
const nodesToAlign = nodes.filter(node =>
selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
const nodesToAlign = getAlignableNodes(nodes, selectedNodes)
if (nodesToAlign.length <= 1) {
handleSelectionContextmenuCancel()
return
}
// Calculate node boundaries for alignment
let minX = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER
const bounds = getAlignBounds(nodesToAlign)
if (!bounds) {
handleSelectionContextmenuCancel()
return
}
// Calculate boundaries of selected nodes
const validNodes = nodesToAlign.filter(node => node.width && node.height)
validNodes.forEach((node) => {
const width = node.width!
const height = node.height!
minX = Math.min(minX, node.position.x)
maxX = Math.max(maxX, node.position.x + width)
minY = Math.min(minY, node.position.y)
maxY = Math.max(maxY, node.position.y + height)
})
// Handle distribute nodes logic
if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
if (distributeNodes) {
// Apply node distribution updates
store.getState().setNodes(distributeNodes)
const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType)
if (distributedNodes) {
store.getState().setNodes(distributedNodes)
handleSelectionContextmenuCancel()
// Clear guide lines
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
// Sync workflow draft
handleSyncWorkflowDraft()
// Save to history
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
return // End function execution
return
}
}
const newNodes = produce(nodes, (draft) => {
// Iterate through all selected nodes
const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
validNodesToAlign.forEach((nodeToAlign) => {
// Find the corresponding node in draft - consistent with handleNodeDrag
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
return
// Use the extracted alignment function
handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
alignNodePosition(currentNode, nodeToAlign, alignType, bounds)
})
})
// Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
try {
// Directly use setNodes to update nodes - consistent with handleNodeDrag
store.getState().setNodes(newNodes)
// Close popup
handleSelectionContextmenuCancel()
// Clear guide lines - consistent with handleNodeDragStop
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
// Sync workflow draft - consistent with handleNodeDragStop
handleSyncWorkflowDraft()
// Save to history - consistent with handleNodeDragStop
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
}
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel])
if (!selectionMenu)
return null
@ -375,6 +131,7 @@ const SelectionContextmenu = () => {
return (
<div
className="absolute z-[9]"
data-testid="selection-contextmenu"
style={{
left: menuPosition.left,
top: menuPosition.top,
@ -382,73 +139,30 @@ const SelectionContextmenu = () => {
ref={ref}
>
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.vertical', { ns: 'workflow' })}
{MENU_SECTIONS.map((section, sectionIndex) => (
<div key={section.titleKey}>
{sectionIndex > 0 && <div className="h-px bg-divider-regular"></div>}
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</div>
{section.items.map((item) => {
const Icon = item.icon
return (
<div
key={item.alignType}
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<Icon className={`h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</div>
)
})}
</div>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className="h-4 w-4" />
{t('operator.alignTop', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className="h-4 w-4 rotate-90" />
{t('operator.alignMiddle', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className="h-4 w-4" />
{t('operator.alignBottom', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className="h-4 w-4 rotate-90" />
{t('operator.distributeVertical', { ns: 'workflow' })}
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.horizontal', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className="h-4 w-4" />
{t('operator.alignLeft', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className="h-4 w-4" />
{t('operator.alignCenter', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className="h-4 w-4" />
{t('operator.alignRight', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className="h-4 w-4" />
{t('operator.distributeHorizontal', { ns: 'workflow' })}
</div>
</div>
))}
</div>
</div>
)