mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 08:58:09 +08:00
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:
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 || ''
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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={{
|
||||
|
||||
242
web/app/components/workflow/selection-contextmenu.helpers.ts
Normal file
242
web/app/components/workflow/selection-contextmenu.helpers.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user