Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-24 19:30:56 +08:00
95 changed files with 10275 additions and 2761 deletions

View File

@ -0,0 +1,24 @@
import { createNodeCrudModuleMock, createUuidModuleMock } from './use-config-test-utils'
describe('use-config-test-utils', () => {
it('createUuidModuleMock should return stable ids from the provided factory', () => {
const mockUuid = vi.fn(() => 'generated-id')
const moduleMock = createUuidModuleMock(mockUuid)
expect(moduleMock.v4()).toBe('generated-id')
expect(mockUuid).toHaveBeenCalledTimes(1)
})
it('createNodeCrudModuleMock should expose inputs and setInputs through the default export', () => {
const setInputs = vi.fn()
const payload = { title: 'Node', type: 'code' }
const moduleMock = createNodeCrudModuleMock<typeof payload>(setInputs)
const result = moduleMock.default('node-1', payload)
expect(moduleMock.__esModule).toBe(true)
expect(result.inputs).toBe(payload)
result.setInputs({ next: true })
expect(setInputs).toHaveBeenCalledWith({ next: true })
})
})

View File

@ -0,0 +1,13 @@
type SetInputsMock = (value: unknown) => void
export const createUuidModuleMock = (getId: () => string) => ({
v4: () => getId(),
})
export const createNodeCrudModuleMock = <T>(setInputs: SetInputsMock) => ({
__esModule: true as const,
default: (_id: string, data: T) => ({
inputs: data,
setInputs,
}),
})

View File

@ -0,0 +1,68 @@
import type { AssignerNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { AssignerNodeInputType, WriteMode } from '../types'
import {
canAssignToVar,
canAssignVar,
ensureAssignerVersion,
filterVarByType,
normalizeAssignedVarType,
updateOperationItems,
} from '../use-config.helpers'
const createInputs = (version: AssignerNodeType['version'] = '1'): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.Assigner,
version,
items: [{
variable_selector: ['conversation', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-1', 'value'],
}],
})
describe('assigner use-config helpers', () => {
it('filters vars and selectors by supported targets', () => {
expect(filterVarByType(VarType.any)({ type: VarType.string } as never)).toBe(true)
expect(filterVarByType(VarType.number)({ type: VarType.any } as never)).toBe(true)
expect(filterVarByType(VarType.number)({ type: VarType.string } as never)).toBe(false)
expect(canAssignVar({} as never, ['conversation', 'total'])).toBe(true)
expect(canAssignVar({} as never, ['sys', 'total'])).toBe(false)
})
it('normalizes assigned variable types for append and passthrough write modes', () => {
expect(normalizeAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string)
expect(normalizeAssignedVarType(VarType.arrayNumber, WriteMode.append)).toBe(VarType.number)
expect(normalizeAssignedVarType(VarType.arrayObject, WriteMode.append)).toBe(VarType.object)
expect(normalizeAssignedVarType(VarType.number, WriteMode.append)).toBe(VarType.string)
expect(normalizeAssignedVarType(VarType.number, WriteMode.increment)).toBe(VarType.number)
expect(normalizeAssignedVarType(VarType.string, WriteMode.clear)).toBe(VarType.string)
})
it('validates assignment targets for append, arithmetic and fallback modes', () => {
expect(canAssignToVar({ type: VarType.number } as never, VarType.number, WriteMode.multiply)).toBe(true)
expect(canAssignToVar({ type: VarType.string } as never, VarType.number, WriteMode.multiply)).toBe(false)
expect(canAssignToVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.number } as never, VarType.arrayNumber, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.object } as never, VarType.arrayObject, WriteMode.append)).toBe(true)
expect(canAssignToVar({ type: VarType.boolean } as never, VarType.arrayString, WriteMode.append)).toBe(false)
expect(canAssignToVar({ type: VarType.string } as never, VarType.string, WriteMode.set)).toBe(true)
})
it('ensures version 2 and replaces operation items immutably', () => {
const legacyInputs = createInputs('1')
const nextItems = [{
variable_selector: ['conversation', 'total'],
input_type: AssignerNodeInputType.constant,
operation: WriteMode.clear,
value: '0',
}]
expect(ensureAssignerVersion(legacyInputs).version).toBe('2')
expect(ensureAssignerVersion(createInputs('2')).version).toBe('2')
expect(updateOperationItems(legacyInputs, nextItems).items).toEqual(nextItems)
expect(legacyInputs.items).toHaveLength(1)
})
})

View File

@ -0,0 +1,98 @@
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetAvailableVars = vi.hoisted(() => vi.fn())
const mockGetCurrentVariableType = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getBeforeNodesInSameBranchIncludeParent: () => [
{ id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } },
],
}),
useWorkflowVariables: () => ({
getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<AssignerNodeType>(mockSetInputs),
}))
vi.mock('../hooks', () => ({
useGetAvailableVars: () => mockGetAvailableVars,
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: () => [
{ id: 'assigner-node', parentId: 'iteration-parent' },
{ id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } },
],
}),
}),
}
})
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
variable_selector: ['conversation', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createPayload = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.Assigner,
version: '1',
items: [createOperation()],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCurrentVariableType.mockReturnValue(VarType.arrayString)
mockGetAvailableVars.mockReturnValue([])
})
it('should normalize legacy payloads, expose write mode groups and derive assigned variable types', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.writeModeTypes).toEqual([WriteMode.overwrite, WriteMode.clear, WriteMode.set])
expect(result.current.writeModeTypesNum).toEqual(writeModeTypesNum)
expect(result.current.getAssignedVarType(['conversation', 'count'])).toBe(VarType.arrayString)
expect(result.current.getToAssignedVarType(VarType.arrayString, WriteMode.append)).toBe(VarType.string)
expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true)
})
it('should update operation lists with version 2 payloads and apply assignment filters', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
const nextItems = [createOperation({ operation: WriteMode.append })]
result.current.handleOperationListChanges(nextItems)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
version: '2',
items: nextItems,
}))
expect(result.current.filterAssignedVar({ isLoopVariable: true } as never, ['node', 'value'])).toBe(true)
expect(result.current.filterAssignedVar({} as never, ['conversation', 'name'])).toBe(true)
expect(result.current.filterToAssignedVar({ type: VarType.string } as never, VarType.arrayString, WriteMode.append)).toBe(true)
expect(result.current.filterToAssignedVar({ type: VarType.number } as never, VarType.arrayString, WriteMode.append)).toBe(false)
})
})

View File

@ -0,0 +1,90 @@
import type { ValueSelector, Var } from '../../types'
import type { AssignerNodeOperation, AssignerNodeType } from './types'
import { produce } from 'immer'
import { VarType } from '../../types'
import { WriteMode } from './types'
export const filterVarByType = (varType: VarType) => {
return (variable: Var) => {
if (varType === VarType.any || variable.type === VarType.any)
return true
return variable.type === varType
}
}
export const normalizeAssignedVarType = (assignedVarType: VarType, writeMode: WriteMode) => {
if (
writeMode === WriteMode.overwrite
|| writeMode === WriteMode.increment
|| writeMode === WriteMode.decrement
|| writeMode === WriteMode.multiply
|| writeMode === WriteMode.divide
|| writeMode === WriteMode.extend
) {
return assignedVarType
}
if (writeMode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return VarType.string
case VarType.arrayNumber:
return VarType.number
case VarType.arrayObject:
return VarType.object
default:
return VarType.string
}
}
return VarType.string
}
export const canAssignVar = (_varPayload: Var, selector: ValueSelector) => {
return selector.join('.').startsWith('conversation')
}
export const canAssignToVar = (
varPayload: Var,
assignedVarType: VarType,
writeMode: WriteMode,
) => {
if (
writeMode === WriteMode.overwrite
|| writeMode === WriteMode.extend
|| writeMode === WriteMode.increment
|| writeMode === WriteMode.decrement
|| writeMode === WriteMode.multiply
|| writeMode === WriteMode.divide
) {
return varPayload.type === assignedVarType
}
if (writeMode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return varPayload.type === VarType.string
case VarType.arrayNumber:
return varPayload.type === VarType.number
case VarType.arrayObject:
return varPayload.type === VarType.object
default:
return false
}
}
return true
}
export const ensureAssignerVersion = (newInputs: AssignerNodeType) => produce(newInputs, (draft) => {
if (draft.version !== '2')
draft.version = '2'
})
export const updateOperationItems = (
inputs: AssignerNodeType,
items: AssignerNodeOperation[],
) => produce(inputs, (draft) => {
draft.items = [...items]
})

View File

@ -1,6 +1,5 @@
import type { ValueSelector, Var } from '../../types'
import type { AssignerNodeOperation, AssignerNodeType } from './types'
import { produce } from 'immer'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import {
@ -10,9 +9,16 @@ import {
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { useGetAvailableVars } from './hooks'
import { WriteMode, writeModeTypesNum } from './types'
import {
canAssignToVar,
canAssignVar,
ensureAssignerVersion,
filterVarByType,
normalizeAssignedVarType,
updateOperationItems,
} from './use-config.helpers'
import { convertV1ToV2 } from './utils'
const useConfig = (id: string, rawPayload: AssignerNodeType) => {
@ -20,15 +26,6 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode()
const getAvailableVars = useGetAvailableVars()
const filterVar = (varType: VarType) => {
return (v: Var) => {
if (varType === VarType.any)
return true
if (v.type === VarType.any)
return true
return v.type === varType
}
}
const store = useStoreApi()
const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
@ -44,11 +41,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
}, [getBeforeNodesInSameBranchIncludeParent, id])
const { inputs, setInputs } = useNodeCrud<AssignerNodeType>(id, payload)
const newSetInputs = useCallback((newInputs: AssignerNodeType) => {
const finalInputs = produce(newInputs, (draft) => {
if (draft.version !== '2')
draft.version = '2'
})
setInputs(finalInputs)
setInputs(ensureAssignerVersion(newInputs))
}, [setInputs])
const { getCurrentVariableType } = useWorkflowVariables()
@ -63,56 +56,21 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
}, [getCurrentVariableType, isInIteration, iterationNode, availableNodes, isChatMode])
const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {
draft.items = [...items]
})
newSetInputs(newInputs)
newSetInputs(updateOperationItems(inputs, items))
}, [inputs, newSetInputs])
const writeModeTypesArr = [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend, WriteMode.removeFirst, WriteMode.removeLast]
const writeModeTypes = [WriteMode.overwrite, WriteMode.clear, WriteMode.set]
const getToAssignedVarType = useCallback((assignedVarType: VarType, write_mode: WriteMode) => {
if (write_mode === WriteMode.overwrite || write_mode === WriteMode.increment || write_mode === WriteMode.decrement
|| write_mode === WriteMode.multiply || write_mode === WriteMode.divide || write_mode === WriteMode.extend) {
return assignedVarType
}
if (write_mode === WriteMode.append) {
if (assignedVarType === VarType.arrayString)
return VarType.string
if (assignedVarType === VarType.arrayNumber)
return VarType.number
if (assignedVarType === VarType.arrayObject)
return VarType.object
}
return VarType.string
}, [])
const getToAssignedVarType = useCallback(normalizeAssignedVarType, [])
const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => {
if (varPayload.isLoopVariable)
return true
return selector.join('.').startsWith('conversation')
return canAssignVar(varPayload, selector)
}, [])
const filterToAssignedVar = useCallback((varPayload: Var, assignedVarType: VarType, write_mode: WriteMode) => {
if (write_mode === WriteMode.overwrite || write_mode === WriteMode.extend || write_mode === WriteMode.increment
|| write_mode === WriteMode.decrement || write_mode === WriteMode.multiply || write_mode === WriteMode.divide) {
return varPayload.type === assignedVarType
}
else if (write_mode === WriteMode.append) {
switch (assignedVarType) {
case VarType.arrayString:
return varPayload.type === VarType.string
case VarType.arrayNumber:
return varPayload.type === VarType.number
case VarType.arrayObject:
return varPayload.type === VarType.object
default:
return false
}
}
return true
}, [])
const filterToAssignedVar = useCallback(canAssignToVar, [])
return {
readOnly,
@ -126,7 +84,7 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
filterAssignedVar,
filterToAssignedVar,
getAvailableVars,
filterVar,
filterVar: filterVarByType,
}
}

View File

@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BodyPayloadValueType, BodyType } from '../../types'
import CurlPanel from '../curl-panel'
import * as curlParser from '../curl-parser'
const {
mockHandleNodeSelect,
mockNotify,
} = vi.hoisted(() => ({
mockHandleNodeSelect: vi.fn(),
mockNotify: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockNotify,
},
}))
describe('curl-panel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('parseCurl', () => {
it('should parse method, headers, json body, and query params from a valid curl command', () => {
const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
expect(error).toBeNull()
expect(node).toMatchObject({
method: 'post',
url: 'https://example.com/users',
headers: 'Authorization: Bearer token',
params: 'page: 1\nsize: 2',
})
})
it('should return an error for invalid curl input', () => {
expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
})
it('should parse form data and attach typed content headers', () => {
const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload')
expect(error).toBeNull()
expect(node).toMatchObject({
method: 'post',
url: 'https://example.com/upload',
headers: 'Content-Type: text/plain',
body: {
type: BodyType.formData,
data: 'file:@report.txt\nname:openai',
},
})
})
it('should parse raw payloads and preserve equals signs in the body value', () => {
const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw')
expect(error).toBeNull()
expect(node?.body).toEqual({
type: BodyType.rawText,
data: [{
type: BodyPayloadValueType.text,
value: 'token=abc=123',
}],
})
})
it.each([
['curl -X', 'Missing HTTP method after -X or --request.'],
['curl --header', 'Missing header value after -H or --header.'],
['curl --data-raw', 'Missing data value after -d, --data, --data-raw, or --data-binary.'],
['curl --form', 'Missing form data after -F or --form.'],
['curl --json', 'Missing JSON data after --json.'],
['curl --form "=broken" https://example.com/upload', 'Invalid form data format.'],
['curl -H "Accept: application/json"', 'Missing URL or url not start with http.'],
])('should return a descriptive error for %s', (command, expectedError) => {
expect(curlParser.parseCurl(command)).toEqual({
node: null,
error: expectedError,
})
})
})
describe('component actions', () => {
it('should import a parsed curl node and reselect the node after saving', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
const handleCurlImport = vi.fn()
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
await user.type(screen.getByRole('textbox'), 'curl https://example.com')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onHide).toHaveBeenCalledTimes(1)
expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({
method: 'get',
url: 'https://example.com',
}))
expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true)
})
it('should notify the user when the curl command is invalid', async () => {
const user = userEvent.setup()
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={vi.fn()}
handleCurlImport={vi.fn()}
/>,
)
await user.type(screen.getByRole('textbox'), 'invalid')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
it('should keep the panel open when parsing returns no node and no error', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
const handleCurlImport = vi.fn()
vi.spyOn(curlParser, 'parseCurl').mockReturnValueOnce({
node: null,
error: null,
})
render(
<CurlPanel
nodeId="node-1"
isShow
onHide={onHide}
handleCurlImport={handleCurlImport}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onHide).not.toHaveBeenCalled()
expect(handleCurlImport).not.toHaveBeenCalled()
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
})
})

View File

@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { useNodesInteractions } from '@/app/components/workflow/hooks'
import { BodyPayloadValueType, BodyType, Method } from '../types'
import { parseCurl } from './curl-parser'
type Props = {
nodeId: string
@ -18,104 +18,6 @@ type Props = {
handleCurlImport: (node: HttpNodeType) => void
}
const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
const node: Partial<HttpNodeType> = {
title: 'HTTP Request',
desc: 'Imported from cURL',
method: undefined,
url: '',
headers: '',
params: '',
body: { type: BodyType.none, data: '' },
}
const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
let hasData = false
for (let i = 1; i < args.length; i++) {
const arg = args[i].replace(/^['"]|['"]$/g, '')
switch (arg) {
case '-X':
case '--request':
if (i + 1 >= args.length)
return { node: null, error: 'Missing HTTP method after -X or --request.' }
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
hasData = true
break
case '-H':
case '--header':
if (i + 1 >= args.length)
return { node: null, error: 'Missing header value after -H or --header.' }
node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
break
case '-d':
case '--data':
case '--data-raw':
case '--data-binary': {
if (i + 1 >= args.length)
return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
const bodyPayload = [{
type: BodyPayloadValueType.text,
value: args[++i].replace(/^['"]|['"]$/g, ''),
}]
node.body = { type: BodyType.rawText, data: bodyPayload }
break
}
case '-F':
case '--form': {
if (i + 1 >= args.length)
return { node: null, error: 'Missing form data after -F or --form.' }
if (node.body?.type !== BodyType.formData)
node.body = { type: BodyType.formData, data: '' }
const formData = args[++i].replace(/^['"]|['"]$/g, '')
const [key, ...valueParts] = formData.split('=')
if (!key)
return { node: null, error: 'Invalid form data format.' }
let value = valueParts.join('=')
// To support command like `curl -F "file=@/path/to/file;type=application/zip"`
// the `;type=application/zip` should translate to `Content-Type: application/zip`
const typeRegex = /^(.+?);type=(.+)$/
const typeMatch = typeRegex.exec(value)
if (typeMatch) {
const [, actualValue, mimeType] = typeMatch
value = actualValue
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
}
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
break
}
case '--json':
if (i + 1 >= args.length)
return { node: null, error: 'Missing JSON data after --json.' }
node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
break
default:
if (arg.startsWith('http') && !node.url)
node.url = arg
break
}
}
// Determine final method
node.method = node.method || (hasData ? Method.post : Method.get)
if (!node.url)
return { node: null, error: 'Missing URL or url not start with http.' }
// Extract query params from URL
const urlParts = node.url?.split('?') || []
if (urlParts.length > 1) {
node.url = urlParts[0]
node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
}
return { node: node as HttpNodeType, error: null }
}
const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
const [inputString, setInputString] = useState('')
const { handleNodeSelect } = useNodesInteractions()

View File

@ -0,0 +1,171 @@
import type { HttpNodeType } from '../types'
import { BodyPayloadValueType, BodyType, Method } from '../types'
const METHOD_ARG_FLAGS = new Set(['-X', '--request'])
const HEADER_ARG_FLAGS = new Set(['-H', '--header'])
const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary'])
const FORM_ARG_FLAGS = new Set(['-F', '--form'])
type ParseStepResult = {
error: string | null
nextIndex: number
hasData?: boolean
}
const stripWrappedQuotes = (value: string) => {
return value.replace(/^['"]|['"]$/g, '')
}
const parseCurlArgs = (curlCommand: string) => {
return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
}
const buildDefaultNode = (): Partial<HttpNodeType> => ({
title: 'HTTP Request',
desc: 'Imported from cURL',
method: undefined,
url: '',
headers: '',
params: '',
body: { type: BodyType.none, data: '' },
})
const extractUrlParams = (url: string) => {
const urlParts = url.split('?')
if (urlParts.length <= 1)
return { url, params: '' }
return {
url: urlParts[0],
params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '),
}
}
const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => {
if (index + 1 >= args.length)
return { value: null, error }
return {
value: stripWrappedQuotes(args[index + 1]),
error: null,
}
}
const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index, hasData: false }
node.method = (nextArg.value.toLowerCase() as Method) || Method.get
return { error: null, nextIndex: index + 1, hasData: true }
}
const applyHeaderArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.headers += `${node.headers ? '\n' : ''}${nextArg.value}`
return { error: null, nextIndex: index + 1 }
}
const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.body = {
type: BodyType.rawText,
data: [{ type: BodyPayloadValueType.text, value: nextArg.value }],
}
return { error: null, nextIndex: index + 1 }
}
const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
if (node.body?.type !== BodyType.formData)
node.body = { type: BodyType.formData, data: '' }
const [key, ...valueParts] = nextArg.value.split('=')
if (!key)
return { error: 'Invalid form data format.', nextIndex: index }
let value = valueParts.join('=')
const typeMatch = /^(.+?);type=(.+)$/.exec(value)
if (typeMatch) {
const [, actualValue, mimeType] = typeMatch
value = actualValue
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
}
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
return { error: null, nextIndex: index + 1 }
}
const applyJsonArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
const nextArg = getNextArg(args, index, 'Missing JSON data after --json.')
if (nextArg.error || nextArg.value === null)
return { error: nextArg.error, nextIndex: index }
node.body = { type: BodyType.json, data: nextArg.value }
return { error: null, nextIndex: index + 1 }
}
const handleCurlArg = (
arg: string,
node: Partial<HttpNodeType>,
args: string[],
index: number,
): ParseStepResult => {
if (METHOD_ARG_FLAGS.has(arg))
return applyMethodArg(node, args, index)
if (HEADER_ARG_FLAGS.has(arg))
return applyHeaderArg(node, args, index)
if (DATA_ARG_FLAGS.has(arg))
return applyDataArg(node, args, index)
if (FORM_ARG_FLAGS.has(arg))
return applyFormArg(node, args, index)
if (arg === '--json')
return applyJsonArg(node, args, index)
if (arg.startsWith('http') && !node.url)
node.url = arg
return { error: null, nextIndex: index, hasData: false }
}
export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
const node = buildDefaultNode()
const args = parseCurlArgs(curlCommand)
let hasData = false
for (let i = 1; i < args.length; i++) {
const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i)
if (result.error)
return { node: null, error: result.error }
hasData ||= Boolean(result.hasData)
i = result.nextIndex
}
node.method = node.method || (hasData ? Method.post : Method.get)
if (!node.url)
return { node: null, error: 'Missing URL or url not start with http.' }
const parsedUrl = extractUrlParams(node.url)
node.url = parsedUrl.url
node.params = parsedUrl.params
return { node: node as HttpNodeType, error: null }
}

View File

@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react'
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
describe('variable-in-markdown', () => {
describe('rehypeVariable', () => {
it('should replace variable tokens with variable elements and preserve surrounding text', () => {
const tree = {
children: [
{
type: 'text',
value: 'Hello {{#node.field#}} world',
},
],
}
rehypeVariable()(tree)
expect(tree.children).toEqual([
{ type: 'text', value: 'Hello ' },
{
type: 'element',
tagName: 'variable',
properties: { dataPath: '{{#node.field#}}' },
children: [],
},
{ type: 'text', value: ' world' },
])
})
it('should ignore note tokens while processing variable nodes', () => {
const tree = {
children: [
{
type: 'text',
value: 'Hello {{#$node.field#}} world',
},
],
}
rehypeVariable()(tree)
expect(tree.children).toEqual([
{
type: 'text',
value: 'Hello {{#$node.field#}} world',
},
])
})
})
describe('rehypeNotes', () => {
it('should replace note tokens with section nodes and update the parent tag name', () => {
const tree = {
tagName: 'p',
children: [
{
type: 'text',
value: 'See {{#$node.title#}} please',
},
],
}
rehypeNotes()(tree)
expect(tree.tagName).toBe('div')
expect(tree.children).toEqual([
{ type: 'text', value: 'See ' },
{
type: 'element',
tagName: 'section',
properties: { dataName: 'title' },
children: [],
},
{ type: 'text', value: ' please' },
])
})
})
describe('rendering', () => {
it('should format variable paths for display', () => {
render(<Variable path="{{#node.field#}}" />)
expect(screen.getByText('{{node/field}}')).toBeInTheDocument()
})
it('should render note values and replace node ids with labels for variable defaults', () => {
const { rerender } = render(
<Note
defaultInput={{
type: 'variable',
selector: ['node-1', 'output'],
value: '',
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
)
expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument()
rerender(
<Note
defaultInput={{
type: 'constant',
value: 'Plain value',
selector: [],
}}
nodeName={nodeId => nodeId}
/>,
)
expect(screen.getByText('Plain value')).toBeInTheDocument()
})
})
})

View File

@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types'
const variableRegex = /\{\{#(.+?)#\}\}/g
const noteRegex = /\{\{#\$(.+?)#\}\}/g
export function rehypeVariable() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
type MarkdownNode = {
type?: string
value?: string
tagName?: string
properties?: Record<string, string>
children?: MarkdownNode[]
}
type SplitMatchResult = {
tagName: string
properties: Record<string, string>
}
const splitTextNode = (
value: string,
regex: RegExp,
createMatchNode: (match: RegExpExecArray) => SplitMatchResult,
) => {
const parts: MarkdownNode[] = []
let lastIndex = 0
let match = regex.exec(value)
while (match !== null) {
if (match.index > lastIndex)
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
const { tagName, properties } = createMatchNode(match)
parts.push({
type: 'element',
tagName,
properties,
children: [],
})
lastIndex = match.index + match[0].length
match = regex.exec(value)
}
if (!parts.length)
return parts
if (lastIndex < value.length)
parts.push({ type: 'text', value: value.slice(lastIndex) })
return parts
}
const visitTextNodes = (
node: MarkdownNode,
transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null,
) => {
if (!node.children)
return
let index = 0
while (index < node.children.length) {
const child = node.children[index]
if (child.type === 'text' && typeof child.value === 'string') {
const nextNodes = transform(child.value, node)
if (nextNodes) {
node.children.splice(index, 1, ...nextNodes)
index += nextNodes.length
continue
}
}
visitTextNodes(child, transform)
index++
}
}
const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => {
return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => {
return `#${nodeName(nodeId)}${separator}`
})
}
const formatVariablePath = (path: string) => {
return path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
export function rehypeVariable() {
return (tree: MarkdownNode) => {
visitTextNodes(tree, (value) => {
variableRegex.lastIndex = 0
noteRegex.lastIndex = 0
if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
variableRegex.lastIndex = 0
m = variableRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
if (!variableRegex.test(value) || noteRegex.test(value))
return null
parts.push({
type: 'element',
tagName: 'variable',
properties: { dataPath: m[0].trim() },
children: [],
})
last = m.index + m[0].length
m = variableRegex.exec(value)
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
variableRegex.lastIndex = 0
return splitTextNode(value, variableRegex, match => ({
tagName: 'variable',
properties: { dataPath: match[0].trim() },
}))
})
}
}
export function rehypeNotes() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
return (tree: MarkdownNode) => {
visitTextNodes(tree, (value, parent) => {
noteRegex.lastIndex = 0
if (!noteRegex.test(value))
return null
noteRegex.lastIndex = 0
if (node.type === 'text' && noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
noteRegex.lastIndex = 0
m = noteRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
const name = m[0].split('.').slice(-1)[0].replace('#}}', '')
parts.push({
type: 'element',
tagName: 'section',
properties: { dataName: name },
children: [],
})
last = m.index + m[0].length
m = noteRegex.exec(value)
parent.tagName = 'div'
return splitTextNode(value, noteRegex, (match) => {
const name = match[0].split('.').slice(-1)[0].replace('#}}', '')
return {
tagName: 'section',
properties: { dataName: name },
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
parent.tagName = 'div' // h2 can not in p. In note content include the h2
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
})
})
}
}
export const Variable: React.FC<{ path: string }> = ({ path }) => {
return (
<span className="text-text-accent">
{
path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
{formatVariablePath(path)}
</span>
)
}
@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
const isVariable = defaultInput.type === 'variable'
const path = `{{#${defaultInput.selector.join('.')}#}}`
let newPath = path
if (path) {
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
return `#${nodeName(nodeId)}${sep}`
})
}
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}

View File

@ -0,0 +1,172 @@
import type { IfElseNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { LogicalOperator } from '../types'
import {
addCase,
addCondition,
addSubVariableCondition,
filterAllVars,
filterNumberVars,
getVarsIsVarFileAttribute,
removeCase,
removeCondition,
removeSubVariableCondition,
sortCases,
toggleConditionLogicalOperator,
toggleSubVariableConditionLogicalOperator,
updateCondition,
updateSubVariableCondition,
} from '../use-config.helpers'
type TestIfElseInputs = ReturnType<typeof createInputs>
const createInputs = (): IfElseNodeType => ({
title: 'If/Else',
desc: '',
type: BlockEnum.IfElse,
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node', 'value'],
comparison_operator: 'contains',
value: '',
}],
}],
_targetBranches: [
{ id: 'case-1', name: 'Case 1' },
{ id: 'false', name: 'Else' },
],
} as unknown as IfElseNodeType)
describe('if-else use-config helpers', () => {
it('filters vars and derives file attribute flags', () => {
expect(filterAllVars()).toBe(true)
expect(filterNumberVars({ type: VarType.number } as never)).toBe(true)
expect(filterNumberVars({ type: VarType.string } as never)).toBe(false)
expect(getVarsIsVarFileAttribute(createInputs().cases, selector => selector[1] === 'value')).toEqual({
'condition-1': true,
})
})
it('adds, removes and sorts cases while keeping target branches aligned', () => {
const added = addCase(createInputs())
expect(added.cases).toHaveLength(2)
expect(added._targetBranches?.map(branch => branch.id)).toContain('false')
const removed = removeCase(added, 'case-1')
expect(removed.cases?.some(item => item.case_id === 'case-1')).toBe(false)
const sorted = sortCases(createInputs(), [
{ id: 'display-2', case_id: 'case-2', logical_operator: LogicalOperator.or, conditions: [] },
{ id: 'display-1', case_id: 'case-1', logical_operator: LogicalOperator.and, conditions: [] },
] as unknown as Parameters<typeof sortCases>[1])
expect(sorted.cases?.map(item => item.case_id)).toEqual(['case-2', 'case-1'])
expect(sorted._targetBranches?.map(branch => branch.id)).toEqual(['case-2', 'case-1', 'false'])
})
it('adds, updates, toggles and removes conditions and sub-conditions', () => {
const withCondition = addCondition({
inputs: createInputs(),
caseId: 'case-1',
valueSelector: ['node', 'flag'],
variable: { type: VarType.boolean } as never,
isVarFileAttribute: false,
})
expect(withCondition.cases?.[0]?.conditions).toHaveLength(2)
expect(withCondition.cases?.[0]?.conditions[1]).toEqual(expect.objectContaining({
value: false,
variable_selector: ['node', 'flag'],
}))
const updatedCondition = updateCondition(withCondition, 'case-1', 'condition-1', {
id: 'condition-1',
value: 'next',
comparison_operator: '=',
} as Parameters<typeof updateCondition>[3])
expect(updatedCondition.cases?.[0]?.conditions[0]).toEqual(expect.objectContaining({
value: 'next',
comparison_operator: '=',
}))
const toggled = toggleConditionLogicalOperator(updatedCondition, 'case-1')
expect(toggled.cases?.[0]?.logical_operator).toBe(LogicalOperator.or)
const withSubCondition = addSubVariableCondition(toggled, 'case-1', 'condition-1', 'name')
expect(withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
key: 'name',
value: '',
}))
const firstSubConditionId = withSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]?.id
expect(firstSubConditionId).toBeTruthy()
const updatedSubCondition = updateSubVariableCondition(
withSubCondition,
'case-1',
'condition-1',
firstSubConditionId!,
{ key: 'size', comparison_operator: '>', value: '10' } as TestIfElseInputs['cases'][number]['conditions'][number],
)
expect(updatedSubCondition.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
key: 'size',
value: '10',
}))
const toggledSub = toggleSubVariableConditionLogicalOperator(updatedSubCondition, 'case-1', 'condition-1')
expect(toggledSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.logical_operator).toBe(LogicalOperator.or)
const removedSub = removeSubVariableCondition(
toggledSub,
'case-1',
'condition-1',
firstSubConditionId!,
)
expect(removedSub.cases?.[0]?.conditions[0]?.sub_variable_condition?.conditions).toEqual([])
const removedCondition = removeCondition(removedSub, 'case-1', 'condition-1')
expect(removedCondition.cases?.[0]?.conditions.some(item => item.id === 'condition-1')).toBe(false)
})
it('keeps inputs unchanged when guard branches short-circuit helper updates', () => {
const unchangedWithoutCases = addCase({
...createInputs(),
cases: undefined,
} as unknown as IfElseNodeType)
expect(unchangedWithoutCases.cases).toBeUndefined()
const withoutTargetBranches = addCase({
...createInputs(),
_targetBranches: undefined,
})
expect(withoutTargetBranches._targetBranches).toBeUndefined()
const withoutElseBranch = addCase({
...createInputs(),
_targetBranches: [{ id: 'case-1', name: 'Case 1' }],
})
expect(withoutElseBranch._targetBranches).toEqual([{ id: 'case-1', name: 'Case 1' }])
const unchangedWhenConditionMissing = addSubVariableCondition(createInputs(), 'case-1', 'missing-condition', 'name')
expect(unchangedWhenConditionMissing).toEqual(createInputs())
const unchangedWhenSubConditionMissing = removeSubVariableCondition(createInputs(), 'case-1', 'condition-1', 'missing-sub')
expect(unchangedWhenSubConditionMissing).toEqual(createInputs())
const unchangedWhenCaseIsMissingForCondition = addCondition({
inputs: createInputs(),
caseId: 'missing-case',
valueSelector: ['node', 'value'],
variable: { type: VarType.string } as never,
isVarFileAttribute: false,
})
expect(unchangedWhenCaseIsMissingForCondition).toEqual(createInputs())
const unchangedWhenCaseMissing = toggleConditionLogicalOperator(createInputs(), 'missing-case')
expect(unchangedWhenCaseMissing).toEqual(createInputs())
const unchangedWhenSubVariableGroupMissing = toggleSubVariableConditionLogicalOperator(createInputs(), 'case-1', 'condition-1')
expect(unchangedWhenSubVariableGroupMissing).toEqual(createInputs())
})
})

View File

@ -0,0 +1,266 @@
import type { IfElseNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockHandleEdgeDeleteByDeleteBranch = vi.hoisted(() => vi.fn())
const mockUpdateNodeInternals = vi.hoisted(() => vi.fn())
const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useUpdateNodeInternals: () => mockUpdateNodeInternals,
}
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useEdgesInteractions: () => ({
handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => mockHandleEdgeDeleteByDeleteBranch(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<IfElseNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: (_id: string, { filterVar }: { filterVar: (value: { type: VarType }) => boolean }) => ({
availableVars: filterVar({ type: VarType.number })
? [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'score', type: VarType.number }] }]
: [{ nodeId: 'node-1', title: 'Start', vars: [{ variable: 'answer', type: VarType.string }] }],
availableNodesWithParent: [],
}),
}))
vi.mock('../use-is-var-file-attribute', () => ({
__esModule: true,
default: () => ({
getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args),
}),
}))
const createPayload = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ({
title: 'If Else',
desc: '',
type: BlockEnum.IfElse,
isInIteration: false,
isInLoop: false,
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
}],
}],
_targetBranches: [
{ id: 'case-1', name: 'IF' },
{ id: 'false', name: 'ELSE' },
],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetIsVarFileAttribute.mockReturnValue(false)
})
it('should expose derived vars and file-attribute flags', () => {
const { result } = renderHook(() => useConfig('if-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.filterVar()).toBe(true)
expect(result.current.filterNumberVar({ type: VarType.number } as never)).toBe(true)
expect(result.current.filterNumberVar({ type: VarType.string } as never)).toBe(false)
expect(result.current.nodesOutputVars).toHaveLength(1)
expect(result.current.nodesOutputNumberVars).toHaveLength(1)
expect(result.current.varsIsVarFileAttribute).toEqual({ 'condition-1': false })
})
it('should manage cases and conditions', () => {
const { result } = renderHook(() => useConfig('if-node', createPayload()))
result.current.handleAddCase()
result.current.handleRemoveCase('generated-id')
result.current.handleAddCondition('case-1', ['node-1', 'score'], { type: VarType.number } as never)
result.current.handleUpdateCondition('case-1', 'condition-1', {
id: 'condition-1',
varType: VarType.number,
variable_selector: ['node-1', 'score'],
comparison_operator: ComparisonOperator.largerThan,
value: '3',
})
result.current.handleRemoveCondition('case-1', 'condition-1')
result.current.handleToggleConditionLogicalOperator('case-1')
result.current.handleSortCase([{
id: 'sortable-1',
case_id: 'case-1',
logical_operator: LogicalOperator.or,
conditions: [],
}])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
case_id: 'generated-id',
logical_operator: LogicalOperator.and,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: [
expect.objectContaining({
case_id: 'case-1',
logical_operator: LogicalOperator.or,
}),
],
_targetBranches: [
{ id: 'case-1', name: 'IF' },
{ id: 'false', name: 'ELSE' },
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
variable_selector: ['node-1', 'score'],
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'condition-1',
comparison_operator: ComparisonOperator.largerThan,
value: '3',
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
]),
}))
expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('if-node', 'generated-id')
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('if-node')
})
it('should manage sub-variable conditions', () => {
const payload = createPayload({
cases: [{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'condition-1',
varType: VarType.file,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.exists,
value: '',
sub_variable_condition: {
case_id: 'sub-case-1',
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-1',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
}],
}],
})
const { result } = renderHook(() => useConfig('if-node', payload))
result.current.handleAddSubVariableCondition('case-1', 'condition-1', 'name')
result.current.handleUpdateSubVariableCondition('case-1', 'condition-1', 'sub-1', {
id: 'sub-1',
key: 'size',
varType: VarType.string,
comparison_operator: ComparisonOperator.is,
value: '2',
})
result.current.handleRemoveSubVariableCondition('case-1', 'condition-1', 'sub-1')
result.current.handleToggleSubVariableConditionLogicalOperator('case-1', 'condition-1')
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
key: 'name',
}),
]),
}),
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'sub-1',
key: 'size',
value: '2',
}),
]),
}),
}),
]),
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
cases: expect.arrayContaining([
expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
}),
]),
}),
]),
}))
})
})

View File

@ -0,0 +1,237 @@
import type { Branch, Var } from '../../types'
import type { CaseItem, Condition, IfElseNodeType } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { VarType } from '../../types'
import { LogicalOperator } from './types'
import {
branchNameCorrect,
getOperators,
} from './utils'
export const filterAllVars = () => true
export const filterNumberVars = (varPayload: Var) => varPayload.type === VarType.number
export const getVarsIsVarFileAttribute = (
cases: IfElseNodeType['cases'],
getIsVarFileAttribute: (valueSelector: string[]) => boolean,
) => {
const conditions: Record<string, boolean> = {}
cases?.forEach((caseItem) => {
caseItem.conditions.forEach((condition) => {
if (condition.variable_selector)
conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector)
})
})
return conditions
}
const getTargetBranchesWithNewCase = (targetBranches: Branch[] | undefined, caseId: string) => {
if (!targetBranches)
return targetBranches
const elseCaseIndex = targetBranches.findIndex(branch => branch.id === 'false')
if (elseCaseIndex < 0)
return targetBranches
return branchNameCorrect([
...targetBranches.slice(0, elseCaseIndex),
{
id: caseId,
name: '',
},
...targetBranches.slice(elseCaseIndex),
])
}
export const addCase = (inputs: IfElseNodeType) => produce(inputs, (draft) => {
if (!draft.cases)
return
const caseId = uuid4()
draft.cases.push({
case_id: caseId,
logical_operator: LogicalOperator.and,
conditions: [],
})
draft._targetBranches = getTargetBranchesWithNewCase(draft._targetBranches, caseId)
})
export const removeCase = (
inputs: IfElseNodeType,
caseId: string,
) => produce(inputs, (draft) => {
draft.cases = draft.cases?.filter(item => item.case_id !== caseId)
if (draft._targetBranches)
draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId))
})
export const sortCases = (
inputs: IfElseNodeType,
newCases: (CaseItem & { id: string })[],
) => produce(inputs, (draft) => {
draft.cases = newCases.filter(Boolean).map(item => ({
id: item.id,
case_id: item.case_id,
logical_operator: item.logical_operator,
conditions: item.conditions,
}))
draft._targetBranches = branchNameCorrect([
...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })),
{ id: 'false', name: '' },
])
})
export const addCondition = ({
inputs,
caseId,
valueSelector,
variable,
isVarFileAttribute,
}: {
inputs: IfElseNodeType
caseId: string
valueSelector: string[]
variable: Var
isVarFileAttribute: boolean
}) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (!targetCase)
return
targetCase.conditions.push({
id: uuid4(),
varType: variable.type,
variable_selector: valueSelector,
comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: (variable.type === VarType.boolean || variable.type === VarType.arrayBoolean) ? false : '',
})
})
export const removeCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId)
})
export const updateCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
nextCondition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, nextCondition)
})
export const toggleConditionLogicalOperator = (
inputs: IfElseNodeType,
caseId: string,
) => produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (!targetCase)
return
targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and
})
export const addSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
key?: string,
) => produce(inputs, (draft) => {
const condition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
if (!condition)
return
if (!condition.sub_variable_condition) {
condition.sub_variable_condition = {
case_id: uuid4(),
logical_operator: LogicalOperator.and,
conditions: [],
}
}
condition.sub_variable_condition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
})
export const removeSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
subConditionId: string,
) => produce(inputs, (draft) => {
const subVariableCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
if (!subVariableCondition)
return
subVariableCondition.conditions = subVariableCondition.conditions.filter(item => item.id !== subConditionId)
})
export const updateSubVariableCondition = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
subConditionId: string,
nextCondition: Condition,
) => produce(inputs, (draft) => {
const targetSubCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
?.conditions
.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, nextCondition)
})
export const toggleSubVariableConditionLogicalOperator = (
inputs: IfElseNodeType,
caseId: string,
conditionId: string,
) => produce(inputs, (draft) => {
const targetSubVariableCondition = draft.cases
?.find(item => item.case_id === caseId)
?.conditions
.find(item => item.id === conditionId)
?.sub_variable_condition
if (!targetSubVariableCondition)
return
targetSubVariableCondition.logical_operator = targetSubVariableCondition.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and
})

View File

@ -12,33 +12,48 @@ import type {
HandleUpdateSubVariableCondition,
IfElseNodeType,
} from './types'
import { produce } from 'immer'
import { useCallback, useMemo } from 'react'
import {
useCallback,
useMemo,
useRef,
} from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { v4 as uuid4 } from 'uuid'
import {
useEdgesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { LogicalOperator } from './types'
import useIsVarFileAttribute from './use-is-var-file-attribute'
import {
branchNameCorrect,
getOperators,
} from './utils'
addCase,
addCondition,
addSubVariableCondition,
filterAllVars,
filterNumberVars,
getVarsIsVarFileAttribute,
removeCase,
removeCondition,
removeSubVariableCondition,
sortCases,
toggleConditionLogicalOperator,
toggleSubVariableConditionLogicalOperator,
updateCondition,
updateSubVariableCondition,
} from './use-config.helpers'
import useIsVarFileAttribute from './use-is-var-file-attribute'
const useConfig = (id: string, payload: IfElseNodeType) => {
const updateNodeInternals = useUpdateNodeInternals()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const { inputs, setInputs } = useNodeCrud<IfElseNodeType>(id, payload)
const inputsRef = useRef(inputs)
const handleInputsChange = useCallback((newInputs: IfElseNodeType) => {
inputsRef.current = newInputs
setInputs(newInputs)
}, [setInputs])
const filterVar = useCallback(() => {
return true
}, [])
const filterVar = useCallback(() => filterAllVars(), [])
const {
availableVars,
@ -48,9 +63,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
filterVar,
})
const filterNumberVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.number
}, [])
const filterNumberVar = useCallback((varPayload: Var) => filterNumberVars(varPayload), [])
const {
getIsVarFileAttribute,
@ -61,13 +74,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
})
const varsIsVarFileAttribute = useMemo(() => {
const conditions: Record<string, boolean> = {}
inputs.cases?.forEach((c) => {
c.conditions.forEach((condition) => {
conditions[condition.id] = getIsVarFileAttribute(condition.variable_selector!)
})
})
return conditions
return getVarsIsVarFileAttribute(inputs.cases, getIsVarFileAttribute)
}, [inputs.cases, getIsVarFileAttribute])
const {
@ -79,177 +86,56 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
})
const handleAddCase = useCallback(() => {
const newInputs = produce(inputs, (draft) => {
if (draft.cases) {
const case_id = uuid4()
draft.cases.push({
case_id,
logical_operator: LogicalOperator.and,
conditions: [],
})
if (draft._targetBranches) {
const elseCaseIndex = draft._targetBranches.findIndex(branch => branch.id === 'false')
if (elseCaseIndex > -1) {
draft._targetBranches = branchNameCorrect([
...draft._targetBranches.slice(0, elseCaseIndex),
{
id: case_id,
name: '',
},
...draft._targetBranches.slice(elseCaseIndex),
])
}
}
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(addCase(inputsRef.current))
}, [handleInputsChange])
const handleRemoveCase = useCallback((caseId: string) => {
const newInputs = produce(inputs, (draft) => {
draft.cases = draft.cases?.filter(item => item.case_id !== caseId)
if (draft._targetBranches)
draft._targetBranches = branchNameCorrect(draft._targetBranches.filter(branch => branch.id !== caseId))
handleEdgeDeleteByDeleteBranch(id, caseId)
})
setInputs(newInputs)
}, [inputs, setInputs, id, handleEdgeDeleteByDeleteBranch])
handleEdgeDeleteByDeleteBranch(id, caseId)
handleInputsChange(removeCase(inputsRef.current, caseId))
}, [handleEdgeDeleteByDeleteBranch, handleInputsChange, id])
const handleSortCase = useCallback((newCases: (CaseItem & { id: string })[]) => {
const newInputs = produce(inputs, (draft) => {
draft.cases = newCases.filter(Boolean).map(item => ({
id: item.id,
case_id: item.case_id,
logical_operator: item.logical_operator,
conditions: item.conditions,
}))
draft._targetBranches = branchNameCorrect([
...newCases.filter(Boolean).map(item => ({ id: item.case_id, name: '' })),
{ id: 'false', name: '' },
])
})
setInputs(newInputs)
handleInputsChange(sortCases(inputsRef.current, newCases))
updateNodeInternals(id)
}, [id, inputs, setInputs, updateNodeInternals])
}, [handleInputsChange, id, updateNodeInternals])
const handleAddCondition = useCallback<HandleAddCondition>((caseId, valueSelector, varItem) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
targetCase.conditions.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: (varItem.type === VarType.boolean || varItem.type === VarType.arrayBoolean) ? false : '',
})
}
})
setInputs(newInputs)
}, [getIsVarFileAttribute, inputs, setInputs])
handleInputsChange(addCondition({
inputs: inputsRef.current,
caseId,
valueSelector,
variable: varItem,
isVarFileAttribute: !!getIsVarFileAttribute(valueSelector),
}))
}, [getIsVarFileAttribute, handleInputsChange])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((caseId, conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.conditions = targetCase.conditions.filter(item => item.id !== conditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(removeCondition(inputsRef.current, caseId, conditionId))
}, [handleInputsChange])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((caseId, conditionId, newCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(updateCondition(inputsRef.current, caseId, conditionId, newCondition))
}, [handleInputsChange])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>((caseId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase)
targetCase.logical_operator = targetCase.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(toggleConditionLogicalOperator(inputsRef.current, caseId))
}, [handleInputsChange])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((caseId: string, conditionId: string, key?: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
case_id: uuid4(),
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(addSubVariableCondition(inputsRef.current, caseId, conditionId, key))
}, [handleInputsChange])
const handleRemoveSubVariableCondition = useCallback((caseId: string, conditionId: string, subConditionId: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.cases?.find(item => item.case_id === caseId)?.conditions.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(removeSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId))
}, [handleInputsChange])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((caseId, conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(updateSubVariableCondition(inputsRef.current, caseId, conditionId, subConditionId, newSubCondition))
}, [handleInputsChange])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((caseId, conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCase = draft.cases?.find(item => item.case_id === caseId)
if (targetCase) {
const targetCondition = targetCase.conditions.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
}
})
setInputs(newInputs)
}, [inputs, setInputs])
handleInputsChange(toggleSubVariableConditionLogicalOperator(inputsRef.current, caseId, conditionId))
}, [handleInputsChange])
return {
readOnly,

View File

@ -0,0 +1,111 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildIterationChildCopy,
getIterationChildren,
getIterationContainerBounds,
getIterationContainerResize,
getNextChildNodeTypeCount,
getRestrictedIterationPosition,
} from '../use-interactions.helpers'
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
})
describe('iteration interaction helpers', () => {
it('calculates bounds, resize and drag restriction for iteration containers', () => {
const children = [
createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }),
createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }),
]
const bounds = getIterationContainerBounds(children as Node[])
expect(bounds.rightNode?.id).toBe('b')
expect(bounds.bottomNode?.id).toBe('b')
expect(getIterationContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({
width: 186,
height: 110,
})
expect(getRestrictedIterationPosition(
createNode({
position: { x: -10, y: 160 },
width: 80,
height: 40,
data: { isInIteration: true },
}),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: 16, y: 120 })
expect(getRestrictedIterationPosition(
createNode({
position: { x: 180, y: -4 },
width: 40,
height: 30,
data: { isInIteration: true },
}),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: 144, y: 65 })
})
it('filters iteration children and increments per-type counts', () => {
const typeCount = {} as Parameters<typeof getNextChildNodeTypeCount>[0]
expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(3)
expect(getNextChildNodeTypeCount(typeCount, BlockEnum.Code, 2)).toBe(4)
expect(getIterationChildren([
createNode({ id: 'child', parentId: 'iteration-1' }),
createNode({ id: 'start', parentId: 'iteration-1', type: 'custom-iteration-start' }),
createNode({ id: 'other', parentId: 'other-iteration' }),
] as Node[], 'iteration-1').map(item => item.id)).toEqual(['child'])
})
it('keeps bounds, resize and positions empty when no container restriction applies', () => {
expect(getIterationContainerBounds([])).toEqual({})
expect(getIterationContainerResize(createNode({ width: 300, height: 240 }) as Node, {})).toEqual({
width: undefined,
height: undefined,
})
expect(getRestrictedIterationPosition(
createNode({ data: { isInIteration: true } }),
undefined,
)).toEqual({ x: undefined, y: undefined })
expect(getRestrictedIterationPosition(
createNode({ data: { isInIteration: false } }),
createNode({ width: 200, height: 180 }) as Node,
)).toEqual({ x: undefined, y: undefined })
})
it('builds copied iteration children with iteration metadata', () => {
const child = createNode({
id: 'child',
position: { x: 12, y: 24 },
positionAbsolute: { x: 12, y: 24 },
extent: 'parent',
zIndex: 7,
data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true },
})
const result = buildIterationChildCopy({
child: child as Node,
childNodeType: BlockEnum.Code,
defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'],
title: 'blocks.code 3',
newNodeId: 'iteration-2',
})
expect(result).toEqual(expect.objectContaining({
parentId: 'iteration-2',
zIndex: 7,
data: expect.objectContaining({
title: 'blocks.code 3',
iteration_id: 'iteration-2',
selected: false,
_isBundled: false,
}),
}))
})
})

View File

@ -0,0 +1,181 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import {
createIterationNode,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { ITERATION_PADDING } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { useNodeIterationInteractions } from '../use-interactions'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockGenerateNewNode = vi.hoisted(() => vi.fn())
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Code]: {
defaultValue: {
title: 'Code',
desc: '',
},
},
},
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args),
getNodeCustomTypeByNodeDataType: () => 'custom',
}))
describe('useNodeIterationInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expand the iteration node when children overflow the bounds', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationRerender('iteration-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedNodes = mockSetNodes.mock.calls[0][0]
const updatedIterationNode = updatedNodes.find((node: Node) => node.id === 'iteration-node')
expect(updatedIterationNode.width).toBe(100 + 60 + ITERATION_PADDING.right)
expect(updatedIterationNode.height).toBe(90 + 40 + ITERATION_PADDING.bottom)
})
it('should restrict dragging to the iteration container padding', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 200,
height: 180,
data: { width: 200, height: 180 },
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
const dragResult = result.current.handleNodeIterationChildDrag(createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: -10, y: -5 },
width: 80,
height: 60,
data: { type: BlockEnum.Code, title: 'Child', desc: '', isInIteration: true },
}))
expect(dragResult.restrictPosition).toEqual({
x: ITERATION_PADDING.left,
y: ITERATION_PADDING.top,
})
})
it('should rerender the parent iteration node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createIterationNode({
id: 'iteration-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'iteration-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip iteration rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeIterationInteractions())
result.current.handleNodeIterationChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy iteration children and remap ids', () => {
mockGetNodes.mockReturnValue([
createIterationNode({ id: 'iteration-node' }),
createNode({
id: 'child-node',
parentId: 'iteration-node',
data: { type: BlockEnum.Code, title: 'Child', desc: '' },
}),
createNode({
id: 'same-type-node',
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
}),
])
mockGenerateNewNode.mockReturnValue({
newNode: createNode({
id: 'generated',
parentId: 'new-iteration',
data: { type: BlockEnum.Code, title: 'blocks.code 3', desc: '', iteration_id: 'new-iteration' },
}),
})
const { result } = renderHook(() => useNodeIterationInteractions())
const copyResult = result.current.handleNodeIterationChildrenCopy('iteration-node', 'new-iteration', { existing: 'mapped' })
expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'custom',
parentId: 'new-iteration',
}))
expect(copyResult.copyChildren).toHaveLength(1)
expect(copyResult.newIdMapping).toEqual({
'existing': 'mapped',
'child-node': 'new-iterationgenerated0',
})
})
})

View File

@ -0,0 +1,113 @@
import type {
BlockEnum,
ChildNodeTypeCount,
Node,
} from '../../types'
import {
ITERATION_PADDING,
} from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
type ContainerBounds = {
rightNode?: Node
bottomNode?: Node
}
export const getIterationContainerBounds = (childrenNodes: Node[]): ContainerBounds => {
return childrenNodes.reduce<ContainerBounds>((acc, node) => {
const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width!
? node
: acc.rightNode
const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height!
? node
: acc.bottomNode
return {
rightNode: nextRightNode,
bottomNode: nextBottomNode,
}
}, {})
}
export const getIterationContainerResize = (currentNode: Node, bounds: ContainerBounds) => {
const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width!
? bounds.rightNode.position.x + bounds.rightNode.width! + ITERATION_PADDING.right
: undefined
const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height!
? bounds.bottomNode.position.y + bounds.bottomNode.height! + ITERATION_PADDING.bottom
: undefined
return {
width,
height,
}
}
export const getRestrictedIterationPosition = (node: Node, parentNode?: Node) => {
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (!node.data.isInIteration || !parentNode)
return restrictPosition
if (node.position.y < ITERATION_PADDING.top)
restrictPosition.y = ITERATION_PADDING.top
if (node.position.x < ITERATION_PADDING.left)
restrictPosition.x = ITERATION_PADDING.left
if (node.position.x + node.width! > parentNode.width! - ITERATION_PADDING.right)
restrictPosition.x = parentNode.width! - ITERATION_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode.height! - ITERATION_PADDING.bottom)
restrictPosition.y = parentNode.height! - ITERATION_PADDING.bottom - node.height!
return restrictPosition
}
export const getIterationChildren = (nodes: Node[], nodeId: string) => {
return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_ITERATION_START_NODE)
}
export const getNextChildNodeTypeCount = (
childNodeTypeCount: ChildNodeTypeCount,
childNodeType: BlockEnum,
nodesWithSameTypeCount: number,
) => {
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameTypeCount + 1
else
childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1
return childNodeTypeCount[childNodeType]
}
export const buildIterationChildCopy = ({
child,
childNodeType,
defaultValue,
title,
newNodeId,
}: {
child: Node
childNodeType: BlockEnum
defaultValue: Node['data']
title: string
newNodeId: string
}) => {
return {
type: child.type!,
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title,
iteration_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: child.zIndex,
}
}

View File

@ -8,14 +8,18 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import {
ITERATION_PADDING,
} from '../../constants'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
import {
buildIterationChildCopy,
getIterationChildren,
getIterationContainerBounds,
getIterationContainerResize,
getNextChildNodeTypeCount,
getRestrictedIterationPosition,
} from './use-interactions.helpers'
export const useNodeIterationInteractions = () => {
const { t } = useTranslation()
@ -24,45 +28,21 @@ export const useNodeIterationInteractions = () => {
const handleNodeIterationRerender = useCallback((nodeId: string) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node
const childrenNodes = getIterationChildren(nodes, nodeId)
const resize = getIterationContainerResize(currentNode, getIterationContainerBounds(childrenNodes))
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
if (resize.width || resize.height) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right
n.width = rightNode.position.x + rightNode.width! + ITERATION_PADDING.right
if (resize.width) {
n.data.width = resize.width
n.width = resize.width
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom
if (resize.height) {
n.data.height = resize.height
n.height = resize.height
}
}
})
@ -75,25 +55,8 @@ export const useNodeIterationInteractions = () => {
const handleNodeIterationChildDrag = useCallback((node: Node) => {
const { nodes } = collaborativeWorkflow.getState()
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (node.data.isInIteration) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < ITERATION_PADDING.top)
restrictPosition.y = ITERATION_PADDING.top
if (node.position.x < ITERATION_PADDING.left)
restrictPosition.x = ITERATION_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - ITERATION_PADDING.right)
restrictPosition.x = parentNode!.width! - ITERATION_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - ITERATION_PADDING.bottom)
restrictPosition.y = parentNode!.height! - ITERATION_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
restrictPosition: getRestrictedIterationPosition(node, nodes.find(n => n.id === node.parentId)),
}
}, [collaborativeWorkflow])
@ -108,38 +71,27 @@ export const useNodeIterationInteractions = () => {
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { nodes } = collaborativeWorkflow.getState()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
const childrenNodes = getIterationChildren(nodes, nodeId)
const newIdMapping = { ...idMapping }
const childNodeTypeCount: ChildNodeTypeCount = {}
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
if (!childNodeTypeCount[childNodeType])
childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1
else
childNodeTypeCount[childNodeType] = childNodeTypeCount[childNodeType] + 1
const nextCount = getNextChildNodeTypeCount(childNodeTypeCount, childNodeType, nodesWithSameType.length)
const title = nodesWithSameType.length > 0
? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${nextCount}`
: t(`blocks.${childNodeType}`, { ns: 'workflow' })
const childCopy = buildIterationChildCopy({
child,
childNodeType,
defaultValue: nodesMetaDataMap?.[childNodeType]?.defaultValue as Node['data'],
title,
newNodeId,
})
const { newNode } = generateNewNode({
...childCopy,
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title: nodesWithSameType.length > 0 ? `${t(`blocks.${childNodeType}`, { ns: 'workflow' })} ${childNodeTypeCount[childNodeType]}` : t(`blocks.${childNodeType}`, { ns: 'workflow' }),
iteration_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: child.zIndex,
})
newNode.id = `${newNodeId}${newNode.id + index}`
newIdMapping[child.id] = newNode.id
@ -150,7 +102,7 @@ export const useNodeIterationInteractions = () => {
copyChildren,
newIdMapping,
}
}, [collaborativeWorkflow, t])
}, [collaborativeWorkflow, nodesMetaDataMap, t])
return {
handleNodeIterationRerender,

View File

@ -0,0 +1,108 @@
import type { ListFilterNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { OrderBy } from '../types'
import {
buildFilterCondition,
canFilterVariable,
getItemVarType,
getItemVarTypeShowName,
supportsSubVariable,
updateExtractEnabled,
updateExtractSerial,
updateFilterCondition,
updateFilterEnabled,
updateLimit,
updateListFilterVariable,
updateOrderByEnabled,
updateOrderByKey,
updateOrderByType,
} from '../use-config.helpers'
const createInputs = (): ListFilterNodeType => ({
title: 'List Filter',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node', 'list'],
var_type: VarType.arrayString,
item_var_type: VarType.string,
filter_by: {
enabled: false,
conditions: [{ key: '', comparison_operator: 'contains', value: '' }],
},
extract_by: {
enabled: false,
serial: '',
},
order_by: {
enabled: false,
key: '',
value: OrderBy.DESC,
},
limit: {
enabled: false,
size: 20,
},
} as unknown as ListFilterNodeType)
describe('list operator use-config helpers', () => {
it('maps item var types, labels and filter support', () => {
expect(getItemVarType(VarType.arrayNumber)).toBe(VarType.number)
expect(getItemVarType(VarType.arrayBoolean)).toBe(VarType.boolean)
expect(getItemVarType(undefined)).toBe(VarType.string)
expect(getItemVarTypeShowName(undefined, false)).toBe('?')
expect(getItemVarTypeShowName(VarType.number, true)).toBe('Number')
expect(supportsSubVariable(VarType.arrayFile)).toBe(true)
expect(supportsSubVariable(VarType.arrayString)).toBe(false)
expect(canFilterVariable({ type: VarType.arrayFile } as never)).toBe(true)
expect(canFilterVariable({ type: VarType.string } as never)).toBe(false)
})
it('builds default conditions and updates selected variable metadata', () => {
expect(buildFilterCondition({
itemVarType: VarType.boolean,
isFileArray: false,
})).toEqual(expect.objectContaining({
key: '',
value: false,
}))
expect(buildFilterCondition({
itemVarType: VarType.string,
isFileArray: true,
})).toEqual(expect.objectContaining({
key: 'name',
value: '',
}))
const nextInputs = updateListFilterVariable({
inputs: {
...createInputs(),
order_by: { enabled: true, key: '', value: OrderBy.DESC },
},
variable: ['node', 'files'],
varType: VarType.arrayFile,
itemVarType: VarType.file,
})
expect(nextInputs.var_type).toBe(VarType.arrayFile)
expect(nextInputs.filter_by.conditions[0]).toEqual(expect.objectContaining({ key: 'name' }))
expect(nextInputs.order_by.key).toBe('name')
})
it('updates filter, extract, limit and order by sections', () => {
const condition = { key: 'size', comparison_operator: '>', value: '10' }
expect(updateFilterEnabled(createInputs(), true).filter_by.enabled).toBe(true)
expect(updateFilterCondition(createInputs(), condition as ListFilterNodeType['filter_by']['conditions'][number]).filter_by.conditions[0]).toEqual(condition)
expect(updateLimit(createInputs(), { enabled: true, size: 10 }).limit).toEqual({ enabled: true, size: 10 })
expect(updateExtractEnabled(createInputs(), true).extract_by).toEqual({ enabled: true, serial: '1' })
expect(updateExtractSerial(createInputs(), '2').extract_by.serial).toBe('2')
const orderEnabled = updateOrderByEnabled(createInputs(), true, true)
expect(orderEnabled.order_by).toEqual(expect.objectContaining({
enabled: true,
key: 'name',
value: OrderBy.ASC,
}))
expect(updateOrderByKey(createInputs(), 'created_at').order_by.key).toBe('created_at')
expect(updateOrderByType(createInputs(), OrderBy.DESC).order_by.value).toBe(OrderBy.DESC)
})
})

View File

@ -0,0 +1,183 @@
import type { ListFilterNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
import { ComparisonOperator } from '../../if-else/types'
import { OrderBy } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetCurrentVariableType = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getBeforeNodesInSameBranch: () => [
{ id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } },
],
}),
useWorkflowVariables: () => ({
getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<ListFilterNodeType>(mockSetInputs),
}))
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: () => [
{ id: 'list-node', parentId: 'iteration-parent' },
{ id: 'iteration-parent', data: { title: 'Iteration', type: BlockEnum.Iteration } },
],
}),
}),
}
})
const createPayload = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNodeType => ({
title: 'List Filter',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node-1', 'items'],
var_type: VarType.arrayString,
item_var_type: VarType.string,
filter_by: {
enabled: true,
conditions: [{
key: '',
comparison_operator: ComparisonOperator.equal,
value: '',
}],
},
extract_by: {
enabled: false,
serial: '',
},
order_by: {
enabled: false,
key: '',
value: OrderBy.DESC,
},
limit: {
enabled: false,
size: 10,
},
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCurrentVariableType.mockReturnValue(VarType.arrayString)
})
it('should expose derived variable metadata and filter array-like vars', () => {
const { result } = renderHook(() => useConfig('list-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.varType).toBe(VarType.arrayString)
expect(result.current.itemVarType).toBe(VarType.string)
expect(result.current.itemVarTypeShowName).toBe('String')
expect(result.current.hasSubVariable).toBe(false)
expect(result.current.filterVar({ type: VarType.arrayBoolean } as never)).toBe(true)
expect(result.current.filterVar({ type: VarType.object } as never)).toBe(false)
})
it('should reset filter conditions when the variable changes to file arrays', () => {
mockGetCurrentVariableType.mockReturnValue(VarType.arrayFile)
const payload = createPayload({
order_by: {
enabled: true,
key: '',
value: OrderBy.DESC,
},
})
const { result } = renderHook(() => useConfig('list-node', payload))
result.current.handleVarChanges(['node-2', 'files'])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variable: ['node-2', 'files'],
var_type: VarType.arrayFile,
item_var_type: VarType.file,
filter_by: {
enabled: true,
conditions: [{
key: 'name',
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
order_by: expect.objectContaining({
key: 'name',
}),
}))
})
it('should update filter, extract, limit and order-by settings', () => {
const { result } = renderHook(() => useConfig('list-node', createPayload()))
result.current.handleFilterEnabledChange(false)
result.current.handleFilterChange({
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: 3,
})
result.current.handleLimitChange({ enabled: true, size: 5 })
result.current.handleExtractsEnabledChange(true)
result.current.handleExtractsChange('2')
result.current.handleOrderByEnabledChange(true)
result.current.handleOrderByKeyChange('size')
result.current.handleOrderByTypeChange(OrderBy.ASC)()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
filter_by: expect.objectContaining({ enabled: false }),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
filter_by: expect.objectContaining({
conditions: [{
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: 3,
}],
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
limit: { enabled: true, size: 5 },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
extract_by: { enabled: true, serial: '1' },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
extract_by: { enabled: false, serial: '2' },
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: true,
value: OrderBy.ASC,
key: '',
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: false,
key: 'size',
value: OrderBy.DESC,
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
order_by: expect.objectContaining({
enabled: false,
key: '',
value: OrderBy.ASC,
}),
}))
})
})

View File

@ -0,0 +1,310 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import { VarType } from '../../../../types'
import { ComparisonOperator } from '../../../if-else/types'
import FilterCondition from '../filter-condition'
const { mockUseAvailableVarList } = vi.hoisted(() => ({
mockUseAvailableVarList: vi.fn((_nodeId: string, _options: unknown) => ({
availableVars: [],
availableNodesWithParent: [],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: (nodeId: string, options: unknown) => mockUseAvailableVarList(nodeId, options),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({
value,
onChange,
onFocusChange,
readOnly,
placeholder,
className,
}: {
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
readOnly?: boolean
placeholder?: string
className?: string
}) => (
<input
aria-label="variable-input"
className={className}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
readOnly={readOnly}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../panel/chat-variable-panel/components/bool-value', () => ({
default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => (
<button onClick={() => onChange(!value)}>{value ? 'true' : 'false'}</button>
),
}))
vi.mock('../../../if-else/components/condition-list/condition-operator', () => ({
default: ({
value,
onSelect,
}: {
value: string
onSelect: (value: string) => void
}) => (
<button onClick={() => onSelect(ComparisonOperator.notEqual)}>
operator:
{value}
</button>
),
}))
vi.mock('../sub-variable-picker', () => ({
default: ({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) => (
<button onClick={() => onChange('size')}>
sub-variable:
{value}
</button>
),
}))
describe('FilterCondition', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableVarList.mockReturnValue({
availableVars: [],
availableNodesWithParent: [],
})
})
it('should render a select input for array-backed file conditions and update array values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['document'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByText(/operator:/)).toBeInTheDocument()
expect(screen.getByText(/sub-variable:/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.doc' }))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.image'))
expect(onChange).toHaveBeenCalledWith({
key: 'type',
comparison_operator: ComparisonOperator.in,
value: ['image'],
})
})
it('should render a boolean value control for boolean variables', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'enabled',
comparison_operator: ComparisonOperator.equal,
value: false,
}}
varType={VarType.boolean}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'false' }))
expect(onChange).toHaveBeenCalledWith({
key: 'enabled',
comparison_operator: ComparisonOperator.equal,
value: true,
})
})
it('should render a supported variable input, apply focus styles, and filter vars by expected type', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: 'name',
comparison_operator: ComparisonOperator.equal,
value: 'draft',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
const variableInput = screen.getByRole('textbox', { name: 'variable-input' })
expect(variableInput).toHaveAttribute('placeholder', 'workflow.nodes.http.insertVarPlaceholder')
await user.click(variableInput)
expect(variableInput.className).toContain('border-components-input-border-active')
fireEvent.change(variableInput, { target: { value: 'draft next' } })
expect(onChange).toHaveBeenLastCalledWith({
key: 'name',
comparison_operator: ComparisonOperator.equal,
value: 'draft next',
})
const config = mockUseAvailableVarList.mock.calls[0]?.[1] as unknown as {
filterVar: (varPayload: { type: VarType }) => boolean
}
expect(config.filterVar({ type: VarType.string })).toBe(true)
expect(config.filterVar({ type: VarType.number })).toBe(false)
})
it('should reset operator and value when the sub variable changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FilterCondition
condition={{
key: '',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'sub-variable:' }))
expect(onChange).toHaveBeenCalledWith({
key: 'size',
comparison_operator: ComparisonOperator.largerThan,
value: '',
})
})
it('should render fallback inputs for unsupported keys and hide value inputs for no-value operators', async () => {
const onChange = vi.fn()
const { rerender } = render(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '',
}}
varType={VarType.number}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '42' } })
expect(onChange).toHaveBeenLastCalledWith({
key: 'custom_field',
comparison_operator: ComparisonOperator.equal,
value: '42',
})
rerender(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.empty,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.queryByRole('textbox', { name: 'variable-input' })).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
it('should build transfer-method options and keep empty select option lists stable for unsupported keys', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FilterCondition
condition={{
key: 'transfer_method',
comparison_operator: ComparisonOperator.in,
value: ['local_file'],
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.localUpload' }))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.url'))
expect(onChange).toHaveBeenCalledWith({
key: 'transfer_method',
comparison_operator: ComparisonOperator.in,
value: [TransferMethod.remote_url],
})
rerender(
<FilterCondition
condition={{
key: 'custom_field',
comparison_operator: ComparisonOperator.in,
value: '',
}}
varType={VarType.file}
onChange={onChange}
hasSubVariable={false}
readOnly={false}
nodeId="node-1"
/>,
)
expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument()
})
})

View File

@ -17,6 +17,8 @@ import { ComparisonOperator } from '../../if-else/types'
import { comparisonOperatorNotRequireValue, getOperators } from '../../if-else/utils'
import SubVariablePicker from './sub-variable-picker'
type VariableInputProps = React.ComponentProps<typeof Input>
const optionNameI18NPrefix = 'nodes.ifElse.optionName'
const VAR_INPUT_SUPPORTED_KEYS: Record<string, VarType> = {
@ -37,6 +39,147 @@ type Props = {
nodeId: string
}
const getExpectedVarType = (condition: Condition, varType: VarType) => {
return condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
}
const getSelectOptions = (
condition: Condition,
isSelect: boolean,
t: ReturnType<typeof useTranslation>['t'],
) => {
if (!isSelect)
return []
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
if (condition.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
return []
}
const getFallbackInputType = ({
hasSubVariable,
condition,
varType,
}: {
hasSubVariable: boolean
condition: Condition
varType: VarType
}) => {
return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number))
? 'number'
: 'text'
}
const ValueInput = ({
comparisonOperator,
isSelect,
isArrayValue,
isBoolean,
supportVariableInput,
selectOptions,
condition,
readOnly,
availableVars,
availableNodesWithParent,
onFocusChange,
onChange,
hasSubVariable,
varType,
t,
}: {
comparisonOperator: ComparisonOperator
isSelect: boolean
isArrayValue: boolean
isBoolean: boolean
supportVariableInput: boolean
selectOptions: Array<{ name: string, value: string }>
condition: Condition
readOnly: boolean
availableVars: VariableInputProps['nodesOutputVars']
availableNodesWithParent: VariableInputProps['availableNodes']
onFocusChange: (value: boolean) => void
onChange: (value: unknown) => void
hasSubVariable: boolean
varType: VarType
t: ReturnType<typeof useTranslation>['t']
}) => {
const [isFocus, setIsFocus] = useState(false)
const handleFocusChange = (value: boolean) => {
setIsFocus(value)
onFocusChange(value)
}
if (comparisonOperatorNotRequireValue(comparisonOperator))
return null
if (isSelect) {
return (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => onChange(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
if (isBoolean) {
return (
<BoolValue
value={condition.value as boolean}
onChange={onChange}
/>
)
}
if (supportVariableInput) {
return (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={getConditionValueAsString(condition)}
onChange={onChange}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={handleFocusChange}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
return (
<input
type={getFallbackInputType({ hasSubVariable, condition, varType })}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={getConditionValueAsString(condition)}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
)
}
const FilterCondition: FC<Props> = ({
condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' },
varType,
@ -46,9 +189,8 @@ const FilterCondition: FC<Props> = ({
nodeId,
}) => {
const { t } = useTranslation()
const [isFocus, setIsFocus] = useState(false)
const expectedVarType = condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
const expectedVarType = getExpectedVarType(condition, varType)
const supportVariableInput = !!expectedVarType
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
@ -62,24 +204,7 @@ const FilterCondition: FC<Props> = ({
const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type'
const isBoolean = varType === VarType.boolean
const selectOptions = useMemo(() => {
if (isSelect) {
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
if (condition.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
value: item.value,
}))
}
return []
}
return []
}, [condition.comparison_operator, condition.key, isSelect, t])
const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t])
const handleChange = useCallback((key: string) => {
return (value: any) => {
@ -100,67 +225,6 @@ const FilterCondition: FC<Props> = ({
})
}, [onChange, expectedVarType])
// Extract input rendering logic to avoid nested ternary
let inputElement: React.ReactNode = null
if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) {
if (isSelect) {
inputElement = (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => handleChange('value')(item.value)}
className="!text-[13px]"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
)
}
else if (isBoolean) {
inputElement = (
<BoolValue
value={condition.value as boolean}
onChange={handleChange('value')}
/>
)
}
else if (supportVariableInput) {
inputElement = (
<Input
instanceId="filter-condition-input"
className={cn(
isFocus
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-components-input-border-hover bg-components-input-bg-normal',
'w-0 grow rounded-lg border px-3 py-[6px]',
)}
value={
getConditionValueAsString(condition)
}
onChange={handleChange('value')}
readOnly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onFocusChange={setIsFocus}
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
placeholderClassName="!leading-[21px]"
/>
)
}
else {
inputElement = (
<input
type={((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) ? 'number' : 'text'}
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
value={
getConditionValueAsString(condition)
}
onChange={e => handleChange('value')(e.target.value)}
readOnly={readOnly}
/>
)
}
}
return (
<div>
{hasSubVariable && (
@ -179,7 +243,23 @@ const FilterCondition: FC<Props> = ({
file={hasSubVariable ? { key: condition.key } : undefined}
disabled={readOnly}
/>
{inputElement}
<ValueInput
comparisonOperator={condition.comparison_operator}
isSelect={isSelect}
isArrayValue={isArrayValue}
isBoolean={isBoolean}
supportVariableInput={supportVariableInput}
selectOptions={selectOptions}
condition={condition}
readOnly={readOnly}
availableVars={availableVars}
availableNodesWithParent={availableNodesWithParent}
onFocusChange={(_value) => {}}
onChange={handleChange('value')}
hasSubVariable={hasSubVariable}
varType={varType}
t={t}
/>
</div>
</div>
)

View File

@ -0,0 +1,150 @@
import type { ValueSelector, Var, VarType } from '../../types'
import type { Condition, Limit, ListFilterNodeType } from './types'
import { produce } from 'immer'
import { VarType as WorkflowVarType } from '../../types'
import { getOperators } from '../if-else/utils'
import { OrderBy } from './types'
export const getItemVarType = (varType?: VarType) => {
switch (varType) {
case WorkflowVarType.arrayNumber:
return WorkflowVarType.number
case WorkflowVarType.arrayString:
return WorkflowVarType.string
case WorkflowVarType.arrayFile:
return WorkflowVarType.file
case WorkflowVarType.arrayObject:
return WorkflowVarType.object
case WorkflowVarType.arrayBoolean:
return WorkflowVarType.boolean
default:
return varType ?? WorkflowVarType.string
}
}
export const getItemVarTypeShowName = (itemVarType?: VarType, hasVariable?: boolean) => {
if (!hasVariable)
return '?'
const fallbackType = itemVarType || WorkflowVarType.string
return `${fallbackType.substring(0, 1).toUpperCase()}${fallbackType.substring(1)}`
}
export const supportsSubVariable = (varType?: VarType) => varType === WorkflowVarType.arrayFile
export const canFilterVariable = (varPayload: Var) => {
return [
WorkflowVarType.arrayNumber,
WorkflowVarType.arrayString,
WorkflowVarType.arrayBoolean,
WorkflowVarType.arrayFile,
].includes(varPayload.type)
}
export const buildFilterCondition = ({
itemVarType,
isFileArray,
existingKey,
}: {
itemVarType?: VarType
isFileArray: boolean
existingKey?: string
}): Condition => ({
key: (isFileArray && !existingKey) ? 'name' : '',
comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0],
value: itemVarType === WorkflowVarType.boolean ? false : '',
})
export const updateListFilterVariable = ({
inputs,
variable,
varType,
itemVarType,
}: {
inputs: ListFilterNodeType
variable: ValueSelector
varType: VarType
itemVarType: VarType
}) => produce(inputs, (draft) => {
const isFileArray = varType === WorkflowVarType.arrayFile
draft.variable = variable
draft.var_type = varType
draft.item_var_type = itemVarType
draft.filter_by.conditions = [
buildFilterCondition({
itemVarType,
isFileArray,
existingKey: draft.filter_by.conditions[0]?.key,
}),
]
if (isFileArray && draft.order_by.enabled && !draft.order_by.key)
draft.order_by.key = 'name'
})
export const updateFilterEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
) => produce(inputs, (draft) => {
draft.filter_by.enabled = enabled
if (enabled && !draft.filter_by.conditions)
draft.filter_by.conditions = []
})
export const updateFilterCondition = (
inputs: ListFilterNodeType,
condition: Condition,
) => produce(inputs, (draft) => {
draft.filter_by.conditions[0] = condition
})
export const updateLimit = (
inputs: ListFilterNodeType,
limit: Limit,
) => produce(inputs, (draft) => {
draft.limit = limit
})
export const updateExtractEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
) => produce(inputs, (draft) => {
draft.extract_by.enabled = enabled
if (enabled)
draft.extract_by.serial = '1'
})
export const updateExtractSerial = (
inputs: ListFilterNodeType,
value: string,
) => produce(inputs, (draft) => {
draft.extract_by.serial = value
})
export const updateOrderByEnabled = (
inputs: ListFilterNodeType,
enabled: boolean,
hasSubVariable: boolean,
) => produce(inputs, (draft) => {
draft.order_by.enabled = enabled
if (enabled) {
draft.order_by.value = OrderBy.ASC
if (hasSubVariable && !draft.order_by.key)
draft.order_by.key = 'name'
}
})
export const updateOrderByKey = (
inputs: ListFilterNodeType,
key: string,
) => produce(inputs, (draft) => {
draft.order_by.key = key
})
export const updateOrderByType = (
inputs: ListFilterNodeType,
type: OrderBy,
) => produce(inputs, (draft) => {
draft.order_by.value = type
})

View File

@ -1,6 +1,5 @@
import type { ValueSelector, Var } from '../../types'
import type { Condition, Limit, ListFilterNodeType } from './types'
import { produce } from 'immer'
import type { Condition, Limit, ListFilterNodeType, OrderBy } from './types'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import {
@ -10,9 +9,21 @@ import {
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '../../types'
import { getOperators } from '../if-else/utils'
import { OrderBy } from './types'
import {
canFilterVariable,
getItemVarType,
getItemVarTypeShowName,
supportsSubVariable,
updateExtractEnabled,
updateExtractSerial,
updateFilterCondition,
updateFilterEnabled,
updateLimit,
updateListFilterVariable,
updateOrderByEnabled,
updateOrderByKey,
updateOrderByType,
} from './use-config.helpers'
const useConfig = (id: string, payload: ListFilterNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -45,127 +56,59 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
isChatMode,
isConstant: false,
})
let itemVarType
switch (varType) {
case VarType.arrayNumber:
itemVarType = VarType.number
break
case VarType.arrayString:
itemVarType = VarType.string
break
case VarType.arrayFile:
itemVarType = VarType.file
break
case VarType.arrayObject:
itemVarType = VarType.object
break
case VarType.arrayBoolean:
itemVarType = VarType.boolean
break
default:
itemVarType = varType
}
const itemVarType = getItemVarType(varType)
return { varType, itemVarType }
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])
const { varType, itemVarType } = getType()
const itemVarTypeShowName = useMemo(() => {
if (!inputs.variable)
return '?'
return [(itemVarType || VarType.string).substring(0, 1).toUpperCase(), (itemVarType || VarType.string).substring(1)].join('')
}, [inputs.variable, itemVarType])
const itemVarTypeShowName = useMemo(() => getItemVarTypeShowName(itemVarType, !!inputs.variable), [inputs.variable, itemVarType])
const hasSubVariable = ([VarType.arrayFile] as VarType[]).includes(varType)
const hasSubVariable = supportsSubVariable(varType)
const handleVarChanges = useCallback((variable: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
draft.variable = variable as ValueSelector
const { varType, itemVarType } = getType(draft.variable)
const isFileArray = varType === VarType.arrayFile
draft.var_type = varType
draft.item_var_type = itemVarType
draft.filter_by.conditions = [{
key: (isFileArray && !draft.filter_by.conditions[0]?.key) ? 'name' : '',
comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0],
value: itemVarType === VarType.boolean ? false : '',
}]
if (isFileArray && draft.order_by.enabled && !draft.order_by.key)
draft.order_by.key = 'name'
})
setInputs(newInputs)
const nextType = getType(variable as ValueSelector)
setInputs(updateListFilterVariable({
inputs,
variable: variable as ValueSelector,
varType: nextType.varType,
itemVarType: nextType.itemVarType,
}))
}, [getType, inputs, setInputs])
const filterVar = useCallback((varPayload: Var) => {
// Don't know the item struct of VarType.arrayObject, so not support it
return ([VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayFile] as VarType[]).includes(varPayload.type)
}, [])
const filterVar = useCallback((varPayload: Var) => canFilterVariable(varPayload), [])
const handleFilterEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.filter_by.enabled = enabled
if (enabled && !draft.filter_by.conditions)
draft.filter_by.conditions = []
})
setInputs(newInputs)
}, [hasSubVariable, inputs, setInputs])
setInputs(updateFilterEnabled(inputs, enabled))
}, [inputs, setInputs])
const handleFilterChange = useCallback((condition: Condition) => {
const newInputs = produce(inputs, (draft) => {
draft.filter_by.conditions[0] = condition
})
setInputs(newInputs)
setInputs(updateFilterCondition(inputs, condition))
}, [inputs, setInputs])
const handleLimitChange = useCallback((limit: Limit) => {
const newInputs = produce(inputs, (draft) => {
draft.limit = limit
})
setInputs(newInputs)
setInputs(updateLimit(inputs, limit))
}, [inputs, setInputs])
const handleExtractsEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.enabled = enabled
if (enabled)
draft.extract_by.serial = '1'
})
setInputs(newInputs)
setInputs(updateExtractEnabled(inputs, enabled))
}, [inputs, setInputs])
const handleExtractsChange = useCallback((value: string) => {
const newInputs = produce(inputs, (draft) => {
draft.extract_by.serial = value
})
setInputs(newInputs)
setInputs(updateExtractSerial(inputs, value))
}, [inputs, setInputs])
const handleOrderByEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.enabled = enabled
if (enabled) {
draft.order_by.value = OrderBy.ASC
if (hasSubVariable && !draft.order_by.key)
draft.order_by.key = 'name'
}
})
setInputs(newInputs)
setInputs(updateOrderByEnabled(inputs, enabled, hasSubVariable))
}, [hasSubVariable, inputs, setInputs])
const handleOrderByKeyChange = useCallback((key: string) => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.key = key
})
setInputs(newInputs)
setInputs(updateOrderByKey(inputs, key))
}, [inputs, setInputs])
const handleOrderByTypeChange = useCallback((type: OrderBy) => {
return () => {
const newInputs = produce(inputs, (draft) => {
draft.order_by.value = type
})
setInputs(newInputs)
setInputs(updateOrderByType(inputs, type))
}
}, [inputs, setInputs])

View File

@ -184,6 +184,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>

View File

@ -0,0 +1,216 @@
import type { LoopNodeType } from '../types'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import { createUuidModuleMock } from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from '../use-config.helpers'
const mockUuid = vi.hoisted(() => vi.fn())
vi.mock('uuid', () => createUuidModuleMock(() => mockUuid()))
const createInputs = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
logical_operator: LogicalOperator.and,
break_conditions: [],
loop_variables: [],
...overrides,
})
describe('loop use-config helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('canUseAsLoopInput', () => {
it.each([
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
])('should accept %s loop inputs', (type) => {
expect(canUseAsLoopInput({ type } as never)).toBe(true)
})
it('should reject non-array loop inputs', () => {
expect(canUseAsLoopInput({ type: VarType.string } as never)).toBe(false)
})
})
it('should update error handling, loop count and logical operators immutably', () => {
const inputs = createInputs()
const withMode = updateErrorHandleMode(inputs, ErrorHandleMode.ContinueOnError)
const withCount = updateLoopCount(withMode, 6)
const toggled = toggleConditionOperator(withCount)
const toggledBack = toggleConditionOperator(toggled)
expect(withMode.error_handle_mode).toBe(ErrorHandleMode.ContinueOnError)
expect(withCount.loop_count).toBe(6)
expect(toggled.logical_operator).toBe(LogicalOperator.or)
expect(toggledBack.logical_operator).toBe(LogicalOperator.and)
expect(inputs.error_handle_mode).toBe(ErrorHandleMode.Terminated)
expect(inputs.loop_count).toBe(3)
})
it('should add, update and remove break conditions for regular and file attributes', () => {
mockUuid
.mockReturnValueOnce('condition-1')
.mockReturnValueOnce('condition-2')
const withBooleanCondition = addBreakCondition({
inputs: createInputs({ break_conditions: undefined }),
valueSelector: ['tool-node', 'enabled'],
variable: { type: VarType.boolean },
isVarFileAttribute: false,
})
const withFileCondition = addBreakCondition({
inputs: withBooleanCondition,
valueSelector: ['tool-node', 'file', 'transfer_method'],
variable: { type: VarType.file },
isVarFileAttribute: true,
})
const updated = updateBreakCondition(withFileCondition, 'condition-2', {
id: 'condition-2',
varType: VarType.file,
key: 'transfer_method',
variable_selector: ['tool-node', 'file', 'transfer_method'],
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
})
const removed = removeBreakCondition(updated, 'condition-1')
expect(withBooleanCondition.break_conditions).toEqual([
expect.objectContaining({
id: 'condition-1',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: 'false',
}),
])
expect(withFileCondition.break_conditions?.[1]).toEqual(expect.objectContaining({
id: 'condition-2',
varType: VarType.file,
comparison_operator: ComparisonOperator.in,
value: '',
}))
expect(updated.break_conditions?.[1]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: [VarType.file],
}))
expect(removed.break_conditions).toEqual([
expect.objectContaining({ id: 'condition-2' }),
])
})
it('should manage nested sub-variable conditions and ignore missing targets', () => {
mockUuid
.mockReturnValueOnce('sub-condition-1')
.mockReturnValueOnce('sub-condition-2')
const inputs = createInputs({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
key: 'name',
variable_selector: ['tool-node', 'file'],
comparison_operator: ComparisonOperator.contains,
value: '',
}],
})
const untouched = addSubVariableCondition(inputs, 'missing-condition')
const withKeyedSubCondition = addSubVariableCondition(inputs, 'condition-1', 'transfer_method')
const withDefaultKeySubCondition = addSubVariableCondition(withKeyedSubCondition, 'condition-1')
const updated = updateSubVariableCondition(withDefaultKeySubCondition, 'condition-1', 'sub-condition-1', {
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
})
const toggled = toggleSubVariableConditionOperator(updated, 'condition-1')
const removed = removeSubVariableCondition(toggled, 'condition-1', 'sub-condition-1')
const unchangedAfterMissingRemove = removeSubVariableCondition(removed, 'missing-condition', 'sub-condition-2')
expect(untouched).toEqual(inputs)
expect(withKeyedSubCondition.break_conditions?.[0].sub_variable_condition).toEqual({
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-condition-1',
key: 'transfer_method',
varType: VarType.string,
comparison_operator: ComparisonOperator.in,
value: '',
}],
})
expect(withDefaultKeySubCondition.break_conditions?.[0].sub_variable_condition?.conditions[1]).toEqual({
id: 'sub-condition-2',
key: '',
varType: VarType.string,
comparison_operator: undefined,
value: '',
})
expect(updated.break_conditions?.[0].sub_variable_condition?.conditions[0]).toEqual(expect.objectContaining({
comparison_operator: ComparisonOperator.notIn,
value: ['remote_url'],
}))
expect(toggled.break_conditions?.[0].sub_variable_condition?.logical_operator).toBe(LogicalOperator.or)
expect(removed.break_conditions?.[0].sub_variable_condition?.conditions).toEqual([
expect.objectContaining({ id: 'sub-condition-2' }),
])
expect(unchangedAfterMissingRemove).toEqual(removed)
})
it('should add, update and remove loop variables without mutating the source inputs', () => {
mockUuid.mockReturnValueOnce('loop-variable-1')
const inputs = createInputs({ loop_variables: undefined })
const added = addLoopVariable(inputs)
const updated = updateLoopVariable(added, 'loop-variable-1', {
label: 'Loop Value',
value_type: ValueType.variable,
value: ['tool-node', 'result'],
})
const unchanged = updateLoopVariable(updated, 'missing-loop-variable', { label: 'ignored' })
const removed = removeLoopVariable(unchanged, 'loop-variable-1')
expect(added.loop_variables).toEqual([{
id: 'loop-variable-1',
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
}])
expect(updated.loop_variables).toEqual([{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'result'],
}])
expect(unchanged).toEqual(updated)
expect(removed.loop_variables).toEqual([])
expect(inputs.loop_variables).toBeUndefined()
})
})

View File

@ -0,0 +1,221 @@
import type { LoopNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, ValueType, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import { ComparisonOperator, LogicalOperator } from '../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockGetLoopNodeChildren = vi.hoisted(() => vi.fn())
const mockGetIsVarFileAttribute = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { conversationVariables: unknown[], dataSourceList: unknown[] }) => unknown) => selector({
conversationVariables: [],
dataSourceList: [],
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useIsChatMode: () => false,
useWorkflow: () => ({
getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<LoopNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeOutputVars: () => [{ nodeId: 'child-node', title: 'Child', vars: [] }],
}))
vi.mock('../use-is-var-file-attribute', () => ({
__esModule: true,
default: () => ({
getIsVarFileAttribute: (...args: unknown[]) => mockGetIsVarFileAttribute(...args),
}),
}))
const createPayload = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_id: 'loop-node',
logical_operator: LogicalOperator.and,
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
}],
loop_count: 3,
error_handle_mode: ErrorHandleMode.ContinueOnError,
loop_variables: [{
id: 'loop-var-1',
label: 'item',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'value',
}],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetLoopNodeChildren.mockReturnValue([])
mockGetIsVarFileAttribute.mockReturnValue(false)
})
it('should expose derived outputs and input variable filtering', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.childrenNodeVars).toEqual([{ nodeId: 'child-node', title: 'Child', vars: [] }])
expect(result.current.loopChildrenNodes).toHaveLength(1)
expect(result.current.filterInputVar({ type: VarType.arrayNumber } as never)).toBe(true)
expect(result.current.filterInputVar({ type: VarType.string } as never)).toBe(false)
})
it('should update error mode, break conditions and logical operators', () => {
const { result } = renderHook(() => useConfig('loop-node', createPayload()))
result.current.changeErrorResponseMode({ value: ErrorHandleMode.Terminated })
result.current.handleAddCondition(['node-1', 'score'], { type: VarType.number } as never)
result.current.handleUpdateCondition('condition-1', {
id: 'condition-1',
varType: VarType.number,
variable_selector: ['node-1', 'score'],
comparison_operator: ComparisonOperator.largerThan,
value: '3',
})
result.current.handleRemoveCondition('condition-1')
result.current.handleToggleConditionLogicalOperator()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
error_handle_mode: ErrorHandleMode.Terminated,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
variable_selector: ['node-1', 'score'],
varType: VarType.number,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: expect.arrayContaining([
expect.objectContaining({
varType: VarType.number,
comparison_operator: ComparisonOperator.largerThan,
value: '3',
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
logical_operator: LogicalOperator.or,
}))
})
it('should manage sub-variable conditions and loop variables', () => {
const payload = createPayload({
break_conditions: [{
id: 'condition-1',
varType: VarType.file,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [{
id: 'sub-1',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '',
}],
},
}],
})
const { result } = renderHook(() => useConfig('loop-node', payload))
result.current.handleAddSubVariableCondition('condition-1', 'name')
result.current.handleUpdateSubVariableCondition('condition-1', 'sub-1', {
id: 'sub-1',
key: 'size',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: '2',
})
result.current.handleRemoveSubVariableCondition('condition-1', 'sub-1')
result.current.handleToggleSubVariableConditionLogicalOperator('condition-1')
result.current.handleUpdateLoopCount(5)
result.current.handleAddLoopVariable()
result.current.handleRemoveLoopVariable('loop-var-1')
result.current.handleUpdateLoopVariable('loop-var-1', { label: 'updated' })
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
conditions: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
key: 'name',
}),
]),
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
break_conditions: [
expect.objectContaining({
sub_variable_condition: expect.objectContaining({
logical_operator: LogicalOperator.or,
}),
}),
],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_count: 5,
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: expect.arrayContaining([
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
loop_variables: [
expect.objectContaining({
id: 'generated-id',
value_type: ValueType.constant,
}),
],
}))
})
})

View File

@ -0,0 +1,100 @@
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from '../use-interactions.helpers'
const createNode = (overrides: Record<string, unknown> = {}) => ({
id: 'node',
type: 'custom',
position: { x: 0, y: 0 },
width: 100,
height: 80,
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
...overrides,
})
describe('loop interaction helpers', () => {
it('calculates bounds and container resize from overflowing children', () => {
const children = [
createNode({ id: 'a', position: { x: 20, y: 10 }, width: 80, height: 40 }),
createNode({ id: 'b', position: { x: 120, y: 60 }, width: 50, height: 30 }),
]
const bounds = getContainerBounds(children as Node[])
expect(bounds.rightNode?.id).toBe('b')
expect(bounds.bottomNode?.id).toBe('b')
expect(getContainerResize(createNode({ width: 120, height: 80 }) as Node, bounds)).toEqual({
width: 186,
height: 110,
})
expect(getContainerResize(createNode({ width: 300, height: 300 }), bounds)).toEqual({
width: undefined,
height: undefined,
})
})
it('restricts loop positions only for loop children and filters loop-start nodes', () => {
const parent = createNode({ id: 'parent', width: 200, height: 180 })
expect(getRestrictedLoopPosition(createNode({ data: { isInLoop: false } }) as Node, parent as Node)).toEqual({ x: undefined, y: undefined })
expect(getRestrictedLoopPosition(
createNode({
position: { x: -10, y: 160 },
width: 80,
height: 40,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 16, y: 120 })
expect(getRestrictedLoopPosition(
createNode({
position: { x: 180, y: -4 },
width: 40,
height: 30,
data: { isInLoop: true },
}),
parent as Node,
)).toEqual({ x: 144, y: 65 })
expect(getLoopChildren([
createNode({ id: 'child', parentId: 'loop-1' }),
createNode({ id: 'start', parentId: 'loop-1', type: 'custom-loop-start' }),
createNode({ id: 'other', parentId: 'other-loop' }),
] as Node[], 'loop-1').map(item => item.id)).toEqual(['child'])
})
it('builds copied loop children with derived title and loop metadata', () => {
const child = createNode({
id: 'child',
position: { x: 12, y: 24 },
positionAbsolute: { x: 12, y: 24 },
extent: 'parent',
data: { type: BlockEnum.Code, title: 'Original', desc: 'child', selected: true },
})
const result = buildLoopChildCopy({
child: child as Node,
childNodeType: BlockEnum.Code,
defaultValue: { title: 'Code', desc: '', type: BlockEnum.Code } as Node['data'],
nodesWithSameTypeCount: 2,
newNodeId: 'loop-2',
index: 3,
})
expect(result.newId).toBe('loop-23')
expect(result.params).toEqual(expect.objectContaining({
parentId: 'loop-2',
zIndex: 1002,
data: expect.objectContaining({
title: 'Code 3',
isInLoop: true,
loop_id: 'loop-2',
selected: false,
_isBundled: false,
}),
}))
})
})

View File

@ -0,0 +1,174 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import {
createLoopNode,
createNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { LOOP_PADDING } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { useNodeLoopInteractions } from '../use-interactions'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockGenerateNewNode = vi.hoisted(() => vi.fn())
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
}
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Code]: {
defaultValue: {
title: 'Code',
},
},
},
}),
}))
vi.mock('@/app/components/workflow/utils', () => ({
generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args),
getNodeCustomTypeByNodeDataType: () => 'custom',
}))
describe('useNodeLoopInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expand the loop node when children overflow the bounds', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopRerender('loop-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedNodes = mockSetNodes.mock.calls[0][0]
const updatedLoopNode = updatedNodes.find((node: Node) => node.id === 'loop-node')
expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right)
expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom)
})
it('should restrict dragging to the loop container padding', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 200,
height: 180,
data: { width: 200, height: 180 },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
const dragResult = result.current.handleNodeLoopChildDrag(createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: -10, y: -5 },
width: 80,
height: 60,
data: { type: BlockEnum.Code, title: 'Child', desc: '', isInLoop: true },
}))
expect(dragResult.restrictPosition).toEqual({
x: LOOP_PADDING.left,
y: LOOP_PADDING.top,
})
})
it('should rerender the parent loop node when a child size changes', () => {
mockGetNodes.mockReturnValue([
createLoopNode({
id: 'loop-node',
width: 120,
height: 80,
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('child-node')
expect(mockSetNodes).toHaveBeenCalledTimes(1)
})
it('should skip loop rerender when the resized node has no parent', () => {
mockGetNodes.mockReturnValue([
createNode({
id: 'standalone-node',
data: { type: BlockEnum.Code, title: 'Standalone', desc: '' },
}),
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopChildSizeChange('standalone-node')
expect(mockSetNodes).not.toHaveBeenCalled()
})
it('should copy loop children and remap ids', () => {
mockGetNodes.mockReturnValue([
createLoopNode({ id: 'loop-node' }),
createNode({
id: 'child-node',
parentId: 'loop-node',
data: { type: BlockEnum.Code, title: 'Child', desc: '' },
}),
createNode({
id: 'same-type-node',
data: { type: BlockEnum.Code, title: 'Code', desc: '' },
}),
])
mockGenerateNewNode.mockReturnValue({
newNode: createNode({
id: 'generated',
parentId: 'new-loop',
data: { type: BlockEnum.Code, title: 'Code 3', desc: '', isInLoop: true, loop_id: 'new-loop' },
}),
})
const { result } = renderHook(() => useNodeLoopInteractions())
const copyResult = result.current.handleNodeLoopChildrenCopy('loop-node', 'new-loop', { existing: 'mapped' })
expect(mockGenerateNewNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'custom',
parentId: 'new-loop',
}))
expect(copyResult.copyChildren).toHaveLength(1)
expect(copyResult.newIdMapping).toEqual({
'existing': 'mapped',
'child-node': 'new-loopgeneratednew-loop0',
})
})
})

View File

@ -0,0 +1,241 @@
import type { InputVar, Node, Variable } from '../../../types'
import type { Condition } from '../types'
import { BlockEnum, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER } from '@/config'
import { ComparisonOperator, LogicalOperator } from '../types'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCase,
getVarSelectorsFromCondition,
} from '../use-single-run-form-params.helpers'
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../_base/components/variable/utils', () => ({
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
desc: '',
type,
},
} as Node)
const createInputVar = (variable: string, label: InputVar['label'] = variable): InputVar => ({
type: InputVarType.textInput,
label,
variable,
required: false,
})
const createCondition = (overrides: Partial<Condition> = {}): Condition => ({
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-node', 'value'],
comparison_operator: ComparisonOperator.equal,
value: '',
...overrides,
})
describe('use-single-run-form-params helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should collect var selectors from conditions and nested cases', () => {
const nestedCondition = createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['start-node', 'answer'],
}),
],
},
})
expect(getVarSelectorsFromCondition(nestedCondition)).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
])
expect(getVarSelectorsFromCase({
logical_operator: LogicalOperator.or,
conditions: [
nestedCondition,
createCondition({
id: 'condition-2',
variable_selector: ['other-node', 'result'],
}),
],
})).toEqual([
['tool-node', 'value'],
['start-node', 'answer'],
['other-node', 'result'],
])
})
it('should copy input values and dedupe duplicate or invalid input vars', () => {
const source = {
question: 'hello',
retry: true,
}
const values = createInputVarValues(source)
const deduped = dedupeInputVars([
createInputVar('tool-node.value'),
createInputVar('tool-node.value'),
undefined as unknown as InputVar,
createInputVar('start-node.answer'),
])
expect(values).toEqual(source)
expect(values).not.toBe(source)
expect(deduped).toEqual([
createInputVar('tool-node.value'),
createInputVar('start-node.answer'),
])
})
it('should build used output vars and pass-to-server keys while filtering loop-local selectors', () => {
const startNode = createNode('start-node', 'Start Node', BlockEnum.Start)
const sysNode = createNode('sys', 'System', BlockEnum.Start)
const loopChildrenNodes = [
createNode('tool-a', 'Tool A'),
createNode('tool-b', 'Tool B'),
createNode('current-node', 'Current Node'),
createNode('inner-node', 'Inner Node'),
]
mockGetNodeUsedVars.mockImplementation((node: Node) => {
switch (node.id) {
case 'tool-a':
return [['sys', 'files']]
case 'tool-b':
return [['start-node', 'answer'], ['current-node', 'self'], ['inner-node', 'secret']]
default:
return []
}
})
mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => {
return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key'
})
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys')
const toVarInputs = vi.fn((variables: Variable[]) => variables.map(variable => createInputVar(
variable.variable,
variable.label as InputVar['label'],
)))
const result = buildUsedOutVars({
loopChildrenNodes,
currentNodeId: 'current-node',
canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes],
isNodeInLoop: nodeId => nodeId === 'inner-node',
toVarInputs,
})
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({
variable: 'sys.files',
label: {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
},
}),
expect.objectContaining({
variable: 'start-node.answer',
label: {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
},
}),
])
expect(result.usedOutVars).toEqual([
createInputVar('sys.files', {
nodeType: BlockEnum.Start,
nodeName: 'System',
variable: 'sys.files',
}),
createInputVar('start-node.answer', {
nodeType: BlockEnum.Start,
nodeName: 'Start Node',
variable: 'answer',
}),
])
expect(result.allVarObject).toEqual({
[['sys.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files',
},
[['sys.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'sys_files_backup',
},
[['start-node.answer', 'tool-b', 0].join(VALUE_SELECTOR_DELIMITER)]: {
inSingleRunPassedKey: 'answer_key',
},
})
})
it('should derive dependent vars from payload and filter current node references', () => {
const dependentVars = getDependentVarsFromLoopPayload({
nodeId: 'loop-node',
usedOutVars: [
createInputVar('start-node.answer'),
createInputVar('loop-node.internal'),
],
breakConditions: [
createCondition({
variable_selector: ['tool-node', 'value'],
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [
createCondition({
id: 'sub-condition-1',
variable_selector: ['loop-node', 'ignored'],
}),
],
},
}),
],
loopVariables: [
{
id: 'loop-variable-1',
label: 'Loop Input',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['tool-node', 'next'],
},
{
id: 'loop-variable-2',
label: 'Constant',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'plain-text',
},
],
})
expect(dependentVars).toEqual([
['start-node', 'answer'],
['tool-node', 'value'],
['tool-node', 'next'],
])
})
})

View File

@ -0,0 +1,216 @@
import type { InputVar, Node } from '../../../types'
import type { LoopNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, InputVarType, ValueType, VarType } from '@/app/components/workflow/types'
import { ComparisonOperator, LogicalOperator } from '../types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseIsNodeInLoop = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockFormatTracing = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('../../../hooks', () => ({
useIsNodeInLoop: (...args: unknown[]) => mockUseIsNodeInLoop(...args),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatTracing(...args),
}))
vi.mock('../../_base/components/variable/utils', () => ({
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createLoopNode = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_count: 3,
error_handle_mode: ErrorHandleMode.Terminated,
break_conditions: [],
loop_variables: [],
...overrides,
})
const createVariableNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
type,
desc: '',
},
} as Node)
const createInputVar = (variable: string): InputVar => ({
type: InputVarType.textInput,
label: variable,
variable,
required: false,
})
const createRunTrace = (): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'loop-node',
node_type: BlockEnum.Loop,
title: 'Loop',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 2,
loop_index: 1,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsNodeInLoop.mockReturnValue({
isNodeInLoop: (nodeId: string) => nodeId === 'inner-node',
})
mockUseWorkflow.mockReturnValue({
getLoopNodeChildren: () => [
createVariableNode('tool-a', 'Tool A'),
createVariableNode('loop-node', 'Loop Node'),
createVariableNode('inner-node', 'Inner Node'),
],
getBeforeNodesInSameBranch: () => [
createVariableNode('start-node', 'Start Node', BlockEnum.Start),
],
})
mockGetNodeUsedVars.mockImplementation((node: Node) => {
if (node.id === 'tool-a')
return [['start-node', 'answer']]
if (node.id === 'loop-node')
return [['loop-node', 'item']]
if (node.id === 'inner-node')
return [['inner-node', 'secret']]
return []
})
mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockReturnValue(false)
mockFormatTracing.mockReturnValue([{
id: 'formatted-node',
execution_metadata: { loop_index: 9 },
}])
})
it('should build single-run forms and filter out loop-local variables', () => {
const toVarInputs = vi.fn((variables: Array<{ variable: string }>) => variables.map(item => createInputVar(item.variable)))
const varSelectorsToVarInputs = vi.fn(() => [
createInputVar('tool-a.result'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode({
break_conditions: [{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['tool-a', 'result'],
comparison_operator: ComparisonOperator.equal,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.and,
conditions: [],
},
}],
loop_variables: [{
id: 'loop-variable-1',
label: 'Loop Value',
var_type: VarType.string,
value_type: ValueType.variable,
value: ['start-node', 'answer'],
}],
}),
runInputData: {
question: 'hello',
},
runResult: null as unknown as NodeTracing,
loopRunResult: [],
setRunInputData: vi.fn(),
toVarInputs,
varSelectorsToVarInputs,
}))
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({ variable: 'start-node.answer' }),
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([
createInputVar('start-node.answer'),
createInputVar('tool-a.result'),
createInputVar('start-node.answer'),
])
expect(result.current.forms[0].values).toEqual({ question: 'hello' })
expect(result.current.allVarObject).toEqual({
'start-node.answer@@@tool-a@@@0': {
inSingleRunPassedKey: 'passed_key',
},
})
expect(result.current.getDependentVars()).toEqual([
['start-node', 'answer'],
['tool-a', 'result'],
['start-node', 'answer'],
])
})
it('should forward onChange and merge tracing metadata into node info', () => {
const setRunInputData = vi.fn()
const runResult = createRunTrace()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'loop-node',
payload: createLoopNode(),
runInputData: {},
runResult,
loopRunResult: [runResult],
setRunInputData,
toVarInputs: vi.fn(() => []),
varSelectorsToVarInputs: vi.fn(() => []),
}))
act(() => {
result.current.forms[0].onChange({ retry: true })
})
expect(setRunInputData).toHaveBeenCalledWith({ retry: true })
expect(mockFormatTracing).toHaveBeenCalledWith([runResult], expect.any(Function))
expect(result.current.nodeInfo).toEqual({
id: 'formatted-node',
execution_metadata: expect.objectContaining({
loop_index: 9,
}),
})
})
})

View File

@ -0,0 +1,171 @@
import type { ErrorHandleMode, Var } from '../../types'
import type { Condition, LoopNodeType, LoopVariable } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { ValueType, VarType } from '../../types'
import { LogicalOperator } from './types'
import { getOperators } from './utils'
export const canUseAsLoopInput = (variable: Var) => {
return [
VarType.array,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayFile,
].includes(variable.type)
}
export const updateErrorHandleMode = (
inputs: LoopNodeType,
mode: ErrorHandleMode,
) => produce(inputs, (draft) => {
draft.error_handle_mode = mode
})
export const addBreakCondition = ({
inputs,
valueSelector,
variable,
isVarFileAttribute,
}: {
inputs: LoopNodeType
valueSelector: string[]
variable: { type: VarType }
isVarFileAttribute: boolean
}) => produce(inputs, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions.push({
id: uuid4(),
varType: variable.type,
variable_selector: valueSelector,
comparison_operator: getOperators(variable.type, isVarFileAttribute ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: variable.type === VarType.boolean ? 'false' : '',
})
})
export const removeBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
export const updateBreakCondition = (
inputs: LoopNodeType,
conditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, condition)
})
export const toggleConditionOperator = (inputs: LoopNodeType) => produce(inputs, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
export const addSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
key?: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const comparisonOperators = getOperators(VarType.string, { key: key || '' })
condition.sub_variable_condition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: comparisonOperators[0],
value: '',
})
})
export const removeSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
) => produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition?.sub_variable_condition)
return
condition.sub_variable_condition.conditions = condition.sub_variable_condition.conditions
.filter(item => item.id !== subConditionId)
})
export const updateSubVariableCondition = (
inputs: LoopNodeType,
conditionId: string,
subConditionId: string,
condition: Condition,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
const targetSubCondition = targetCondition?.sub_variable_condition?.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, condition)
})
export const toggleSubVariableConditionOperator = (
inputs: LoopNodeType,
conditionId: string,
) => produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition?.sub_variable_condition) {
targetCondition.sub_variable_condition.logical_operator
= targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
}
})
export const updateLoopCount = (
inputs: LoopNodeType,
value: number,
) => produce(inputs, (draft) => {
draft.loop_count = value
})
export const addLoopVariable = (inputs: LoopNodeType) => produce(inputs, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
export const removeLoopVariable = (
inputs: LoopNodeType,
id: string,
) => produce(inputs, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
export const updateLoopVariable = (
inputs: LoopNodeType,
id: string,
updateData: Partial<LoopVariable>,
) => produce(inputs, (draft) => {
const index = draft.loop_variables?.findIndex(item => item.id === id) ?? -1
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})

View File

@ -9,13 +9,11 @@ import type {
HandleUpdateSubVariableCondition,
LoopNodeType,
} from './types'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useRef,
} from 'react'
import { v4 as uuid4 } from 'uuid'
import { useStore } from '@/app/components/workflow/store'
import {
useAllBuiltInTools,
@ -28,12 +26,25 @@ import {
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { ValueType, VarType } from '../../types'
import { toNodeOutputVars } from '../_base/components/variable/utils'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { LogicalOperator } from './types'
import {
addBreakCondition,
addLoopVariable,
addSubVariableCondition,
canUseAsLoopInput,
removeBreakCondition,
removeLoopVariable,
removeSubVariableCondition,
toggleConditionOperator,
toggleSubVariableConditionOperator,
updateBreakCondition,
updateErrorHandleMode,
updateLoopCount,
updateLoopVariable,
updateSubVariableCondition,
} from './use-config.helpers'
import useIsVarFileAttribute from './use-is-var-file-attribute'
import { getOperators } from './utils'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
@ -50,9 +61,7 @@ const useConfig = (id: string, payload: LoopNodeType) => {
setInputs(newInputs)
}, [setInputs])
const filterInputVar = useCallback((varPayload: Var) => {
return ([VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile] as VarType[]).includes(varPayload.type)
}, [])
const filterInputVar = useCallback((varPayload: Var) => canUseAsLoopInput(varPayload), [])
// output
const { getLoopNodeChildren } = useWorkflow()
@ -78,158 +87,60 @@ const useConfig = (id: string, payload: LoopNodeType) => {
})
const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})
handleInputsChange(newInputs)
}, [inputs, handleInputsChange])
handleInputsChange(updateErrorHandleMode(inputsRef.current, item.value as ErrorHandleMode))
}, [handleInputsChange])
const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions?.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: varItem.type === VarType.boolean ? 'false' : '',
})
})
handleInputsChange(newInputs)
handleInputsChange(addBreakCondition({
inputs: inputsRef.current,
valueSelector,
variable: varItem,
isVarFileAttribute: !!getIsVarFileAttribute(valueSelector),
}))
}, [getIsVarFileAttribute, handleInputsChange])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeBreakCondition(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
})
handleInputsChange(newInputs)
handleInputsChange(updateBreakCondition(inputsRef.current, conditionId, newCondition))
}, [handleInputsChange])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleConditionOperator(inputsRef.current))
}, [handleInputsChange])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
value: '',
})
}
})
handleInputsChange(newInputs)
handleInputsChange(addSubVariableCondition(inputsRef.current, conditionId, key))
}, [handleInputsChange])
const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
handleInputsChange(newInputs)
handleInputsChange(removeSubVariableCondition(inputsRef.current, conditionId, subConditionId))
}, [handleInputsChange])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
})
handleInputsChange(newInputs)
handleInputsChange(updateSubVariableCondition(inputsRef.current, conditionId, subConditionId, newSubCondition))
}, [handleInputsChange])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
const newInputs = produce(inputsRef.current, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
handleInputsChange(newInputs)
handleInputsChange(toggleSubVariableConditionOperator(inputsRef.current, conditionId))
}, [handleInputsChange])
const handleUpdateLoopCount = useCallback((value: number) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_count = value
})
handleInputsChange(newInputs)
handleInputsChange(updateLoopCount(inputsRef.current, value))
}, [handleInputsChange])
const handleAddLoopVariable = useCallback(() => {
const newInputs = produce(inputsRef.current, (draft) => {
if (!draft.loop_variables)
draft.loop_variables = []
draft.loop_variables.push({
id: uuid4(),
label: '',
var_type: VarType.string,
value_type: ValueType.constant,
value: '',
})
})
handleInputsChange(newInputs)
handleInputsChange(addLoopVariable(inputsRef.current))
}, [handleInputsChange])
const handleRemoveLoopVariable = useCallback((id: string) => {
const newInputs = produce(inputsRef.current, (draft) => {
draft.loop_variables = draft.loop_variables?.filter(item => item.id !== id)
})
handleInputsChange(newInputs)
handleInputsChange(removeLoopVariable(inputsRef.current, id))
}, [handleInputsChange])
const handleUpdateLoopVariable = useCallback((id: string, updateData: any) => {
const loopVariables = inputsRef.current.loop_variables || []
const index = loopVariables.findIndex(item => item.id === id)
const newInputs = produce(inputsRef.current, (draft) => {
if (index > -1) {
draft.loop_variables![index] = {
...draft.loop_variables![index],
...updateData,
}
}
})
handleInputsChange(newInputs)
handleInputsChange(updateLoopVariable(inputsRef.current, id, updateData))
}, [handleInputsChange])
return {

View File

@ -0,0 +1,109 @@
import type {
BlockEnum,
Node,
} from '../../types'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
type ContainerBounds = {
rightNode?: Node
bottomNode?: Node
}
export const getContainerBounds = (childrenNodes: Node[]): ContainerBounds => {
return childrenNodes.reduce<ContainerBounds>((acc, node) => {
const nextRightNode = !acc.rightNode || node.position.x + node.width! > acc.rightNode.position.x + acc.rightNode.width!
? node
: acc.rightNode
const nextBottomNode = !acc.bottomNode || node.position.y + node.height! > acc.bottomNode.position.y + acc.bottomNode.height!
? node
: acc.bottomNode
return {
rightNode: nextRightNode,
bottomNode: nextBottomNode,
}
}, {})
}
export const getContainerResize = (currentNode: Node, bounds: ContainerBounds) => {
const width = bounds.rightNode && currentNode.width! < bounds.rightNode.position.x + bounds.rightNode.width!
? bounds.rightNode.position.x + bounds.rightNode.width! + LOOP_PADDING.right
: undefined
const height = bounds.bottomNode && currentNode.height! < bounds.bottomNode.position.y + bounds.bottomNode.height!
? bounds.bottomNode.position.y + bounds.bottomNode.height! + LOOP_PADDING.bottom
: undefined
return {
width,
height,
}
}
export const getRestrictedLoopPosition = (node: Node, parentNode?: Node) => {
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (!node.data.isInLoop || !parentNode)
return restrictPosition
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode.height! - LOOP_PADDING.bottom - node.height!
return restrictPosition
}
export const getLoopChildren = (nodes: Node[], nodeId: string) => {
return nodes.filter(node => node.parentId === nodeId && node.type !== CUSTOM_LOOP_START_NODE)
}
export const buildLoopChildCopy = ({
child,
childNodeType,
defaultValue,
nodesWithSameTypeCount,
newNodeId,
index,
}: {
child: Node
childNodeType: BlockEnum
defaultValue: Node['data']
nodesWithSameTypeCount: number
newNodeId: string
index: number
}) => {
const params = {
type: child.type!,
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameTypeCount > 0 ? `${defaultValue.title} ${nodesWithSameTypeCount + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
}
return {
params,
newId: `${newNodeId}${index}`,
}
}

View File

@ -6,15 +6,17 @@ import { produce } from 'immer'
import { useCallback } from 'react'
import { useNodesMetaData } from '@/app/components/workflow/hooks'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import {
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
} from '../../constants'
import {
generateNewNode,
getNodeCustomTypeByNodeDataType,
} from '../../utils'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
import {
buildLoopChildCopy,
getContainerBounds,
getContainerResize,
getLoopChildren,
getRestrictedLoopPosition,
} from './use-interactions.helpers'
export const useNodeLoopInteractions = () => {
const collaborativeWorkflow = useCollaborativeWorkflow()
@ -23,43 +25,20 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node
const childrenNodes = getLoopChildren(nodes, nodeId)
const resize = getContainerResize(currentNode, getContainerBounds(childrenNodes))
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
if (resize.width || resize.height) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
if (resize.width) {
n.data.width = resize.width
n.width = resize.width
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
if (resize.height) {
n.data.height = resize.height
n.height = resize.height
}
}
})
@ -72,25 +51,8 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopChildDrag = useCallback((node: Node) => {
const { nodes } = collaborativeWorkflow.getState()
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
if (node.data.isInLoop) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
restrictPosition: getRestrictedLoopPosition(node, nodes.find(n => n.id === node.parentId)),
}
}, [collaborativeWorkflow])
@ -105,35 +67,26 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { nodes } = collaborativeWorkflow.getState()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
const childrenNodes = getLoopChildren(nodes, nodeId)
const newIdMapping = { ...idMapping }
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(childNodeType),
data: {
...defaultValue,
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
_dimmed: false,
title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
isInLoop: true,
loop_id: newNodeId,
type: childNodeType,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: LOOP_CHILDREN_Z_INDEX,
const childCopy = buildLoopChildCopy({
child,
childNodeType,
defaultValue: defaultValue as Node['data'],
nodesWithSameTypeCount: nodesWithSameType.length,
newNodeId,
index,
})
newNode.id = `${newNodeId}${newNode.id + index}`
const { newNode } = generateNewNode({
...childCopy.params,
type: getNodeCustomTypeByNodeDataType(childNodeType),
})
newNode.id = `${newNodeId}${newNode.id + childCopy.newId}`
newIdMapping[child.id] = newNode.id
return newNode
})

View File

@ -0,0 +1,131 @@
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopVariable } from './types'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
export function getVarSelectorsFromCase(caseItem: CaseItem): ValueSelector[] {
const vars: ValueSelector[] = []
caseItem.conditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
return vars
}
export function getVarSelectorsFromCondition(condition: Condition): ValueSelector[] {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition?.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
export const createInputVarValues = (runInputData: Record<string, unknown>) => {
const vars: Record<string, unknown> = {}
Object.keys(runInputData).forEach((key) => {
vars[key] = runInputData[key]
})
return vars
}
export const dedupeInputVars = (inputVars: InputVar[]) => {
const seen: Record<string, boolean> = {}
const uniqueInputVars: InputVar[] = []
inputVars.forEach((input) => {
if (!input || seen[input.variable])
return
seen[input.variable] = true
uniqueInputVars.push(input)
})
return uniqueInputVars
}
export const buildUsedOutVars = ({
loopChildrenNodes,
currentNodeId,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}: {
loopChildrenNodes: Node[]
currentNodeId: string
canChooseVarNodes: Node[]
isNodeInLoop: (nodeId: string) => boolean
toVarInputs: (variables: Variable[]) => InputVar[]
}) => {
const vars: ValueSelector[] = []
const seenVarSelectors: Record<string, boolean> = {}
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === currentNodeId)
return
if (isNodeInLoop(varSelector[0]))
return
const varSelectorStr = varSelector.join('.')
if (!seenVarSelectors[varSelectorStr]) {
seenVarSelectors[varSelectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSelectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const usedOutVars = toVarInputs(vars.map((valueSelector) => {
const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title,
variable: isSystemVar(valueSelector) ? valueSelector.join('.') : valueSelector[valueSelector.length - 1],
},
variable: valueSelector.join('.'),
value_selector: valueSelector,
}
}))
return { usedOutVars, allVarObject }
}
export const getDependentVarsFromLoopPayload = ({
nodeId,
usedOutVars,
breakConditions,
loopVariables,
}: {
nodeId: string
usedOutVars: InputVar[]
breakConditions?: Condition[]
loopVariables?: LoopVariable[]
}) => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
breakConditions?.forEach((condition) => {
vars.push(...getVarSelectorsFromCondition(condition))
})
loopVariables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
return vars.filter(item => item[0] !== nodeId)
}

View File

@ -1,15 +1,19 @@
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
import type { CaseItem, Condition, LoopNodeType } from './types'
import type { LoopNodeType } from './types'
import type { NodeTracing } from '@/types/workflow'
import { useCallback, useMemo } from 'react'
import { useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { WorkflowContext } from '@/app/components/workflow/context'
import formatTracing from '@/app/components/workflow/run/utils/format-log'
import { useStore } from '@/app/components/workflow/store'
import { ValueType } from '@/app/components/workflow/types'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { useIsNodeInLoop, useWorkflow } from '../../hooks'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar } from '../_base/components/variable/utils'
import {
buildUsedOutVars,
createInputVarValues,
dedupeInputVars,
getDependentVarsFromLoopPayload,
getVarSelectorsFromCondition,
} from './use-single-run-form-params.helpers'
type Params = {
id: string
@ -37,9 +41,10 @@ const useSingleRunFormParams = ({
const { isNodeInLoop } = useIsNodeInLoop(id)
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const parentAvailableNodes = useStore(useShallow(s => s.parentAvailableNodes)) || []
const workflowStore = useContext(WorkflowContext)
const parentAvailableNodes = workflowStore?.getState().parentAvailableNodes || []
const loopChildrenNodes = getLoopNodeChildren(id)
const beforeNodes = (() => {
const beforeNodes = useMemo(() => {
const baseBeforeNodes = getBeforeNodesInSameBranch(id)
if (!parentAvailableNodes.length)
return baseBeforeNodes
@ -52,59 +57,16 @@ const useSingleRunFormParams = ({
merged.set(node.id, node)
})
return Array.from(merged.values())
})()
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
}, [getBeforeNodesInSameBranch, id, parentAvailableNodes])
const canChooseVarNodes = useMemo(() => [...beforeNodes, ...loopChildrenNodes], [beforeNodes, loopChildrenNodes])
const { usedOutVars, allVarObject } = (() => {
const vars: ValueSelector[] = []
const varObjs: Record<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) { // skip loop node itself variable: item, index
return
}
const isInLoop = isNodeInLoop(varSelector[0])
if (isInLoop) // not pass loop inner variable
return
const varSectorStr = varSelector.join('.')
if (!varObjs[varSectorStr]) {
varObjs[varSectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const res = toVarInputs(vars.map((item) => {
const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `${item.join('.')}`,
value_selector: item,
}
}))
return {
usedOutVars: res,
allVarObject,
}
})()
const { usedOutVars, allVarObject } = useMemo(() => buildUsedOutVars({
loopChildrenNodes,
currentNodeId: id,
canChooseVarNodes,
isNodeInLoop,
toVarInputs,
}), [loopChildrenNodes, id, canChooseVarNodes, isNodeInLoop, toVarInputs])
const nodeInfo = useMemo(() => {
const formattedNodeInfo = formatTracing(loopRunResult, t)[0]
@ -126,38 +88,9 @@ const useSingleRunFormParams = ({
setRunInputData(newPayload)
}, [setRunInputData])
const inputVarValues = (() => {
const vars: Record<string, any> = {}
Object.keys(runInputData)
.forEach((key) => {
vars[key] = runInputData[key]
})
return vars
})()
const inputVarValues = useMemo(() => createInputVarValues(runInputData), [runInputData])
const getVarSelectorsFromCase = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarSelectorsFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarSelectorsFromCondition = (condition: Condition) => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarSelectorsFromCase(condition.sub_variable_condition))
return vars
}
const forms = (() => {
const forms = useMemo(() => {
const allInputs: ValueSelector[] = []
payload.break_conditions?.forEach((condition) => {
const vars = getVarSelectorsFromCondition(condition)
@ -170,16 +103,7 @@ const useSingleRunFormParams = ({
})
const inputVarsFromValue: InputVar[] = []
const varInputs = [...varSelectorsToVarInputs(allInputs), ...inputVarsFromValue]
const existVarsKey: Record<string, boolean> = {}
const uniqueVarInputs: InputVar[] = []
varInputs.forEach((input) => {
if (!input)
return
if (!existVarsKey[input.variable]) {
existVarsKey[input.variable] = true
uniqueVarInputs.push(input)
}
})
const uniqueVarInputs = dedupeInputVars(varInputs)
return [
{
inputs: [...usedOutVars, ...uniqueVarInputs],
@ -187,43 +111,14 @@ const useSingleRunFormParams = ({
onChange: setInputVarValues,
},
]
})()
}, [payload.break_conditions, payload.loop_variables, varSelectorsToVarInputs, usedOutVars, inputVarValues, setInputVarValues])
const getVarFromCaseItem = (caseItem: CaseItem): ValueSelector[] => {
const vars: ValueSelector[] = []
if (caseItem.conditions && caseItem.conditions.length) {
caseItem.conditions.forEach((condition) => {
// eslint-disable-next-line ts/no-use-before-define
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
}
return vars
}
const getVarFromCondition = (condition: Condition): ValueSelector[] => {
const vars: ValueSelector[] = []
if (condition.variable_selector)
vars.push(condition.variable_selector)
if (condition.sub_variable_condition && condition.sub_variable_condition.conditions?.length)
vars.push(...getVarFromCaseItem(condition.sub_variable_condition))
return vars
}
const getDependentVars = () => {
const vars: ValueSelector[] = usedOutVars.map(item => item.variable.split('.'))
payload.break_conditions?.forEach((condition) => {
const conditionVars = getVarFromCondition(condition)
vars.push(...conditionVars)
})
payload.loop_variables?.forEach((loopVariable) => {
if (loopVariable.value_type === ValueType.variable)
vars.push(loopVariable.value)
})
const hasFilterLoopVars = vars.filter(item => item[0] !== id)
return hasFilterLoopVars
}
const getDependentVars = useCallback(() => getDependentVarsFromLoopPayload({
nodeId: id,
usedOutVars,
breakConditions: payload.break_conditions,
loopVariables: payload.loop_variables,
}), [id, usedOutVars, payload.break_conditions, payload.loop_variables])
return {
forms,

View File

@ -75,6 +75,8 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>
<Field

View File

@ -64,6 +64,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
hideDebugWithMultipleModel
debugWithMultipleModel={false}
readonly={readOnly}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>
<Field

View File

@ -0,0 +1,196 @@
import type { WebhookTriggerNodeType } from '../types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
syncVariables,
updateContentType,
updateMethod,
updateSimpleField,
updateSourceFields,
updateWebhookUrls,
} from '../use-config.helpers'
import { WEBHOOK_RAW_VARIABLE_NAME } from '../utils/raw-variable'
const createInputs = (): WebhookTriggerNodeType => ({
title: 'Webhook',
desc: '',
type: BlockEnum.TriggerWebhook,
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: false,
status_code: 200,
response_body: '',
variables: [
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
{ variable: 'body_value', label: 'body', required: true, value_selector: [], value_type: VarType.string },
],
} as unknown as WebhookTriggerNodeType)
describe('trigger webhook config helpers', () => {
it('syncs variables, updates existing ones and validates names', () => {
const notifyError = vi.fn()
const isVarUsedInNodes = vi.fn(([_, variable]) => variable === 'old_param')
const removeUsedVarInNodes = vi.fn()
const draft = {
...createInputs(),
variables: [
{ variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number },
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
],
}
expect(syncVariables({
draft,
id: 'node-1',
newData: [{ name: 'existing_header', type: VarType.string, required: true }],
sourceType: 'header',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(true)
expect(draft.variables).toContainEqual(expect.objectContaining({
variable: 'existing_header',
label: 'header',
required: true,
}))
expect(syncVariables({
draft,
id: 'node-1',
newData: [{ name: '1invalid', type: VarType.string, required: true }],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('varKeyError.notStartWithNumber')
expect(syncVariables({
draft: createInputs(),
id: 'node-1',
newData: [
{ name: 'x-request-id', type: VarType.string, required: true },
{ name: 'x-request-id', type: VarType.string, required: false },
],
sourceType: 'header',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('variableConfig.varName')
expect(syncVariables({
draft: {
...createInputs(),
variables: undefined,
} as unknown as WebhookTriggerNodeType,
id: 'node-1',
newData: [{ name: WEBHOOK_RAW_VARIABLE_NAME, type: VarType.string, required: true }],
sourceType: 'body',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('variableConfig.varName')
expect(syncVariables({
draft: createInputs(),
id: 'node-1',
newData: [{ name: 'existing_header', type: VarType.string, required: true }],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(false)
expect(notifyError).toHaveBeenCalledWith('existing_header')
const removableDraft = {
...createInputs(),
variables: [
{ variable: 'old_param', label: 'param', required: true, value_selector: [], value_type: VarType.number },
],
}
expect(syncVariables({
draft: removableDraft,
id: 'node-1',
newData: [],
sourceType: 'param',
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})).toBe(true)
expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'old_param'])
})
it('updates content, source fields and webhook urls', () => {
const removeUsedVarInNodes = vi.fn()
const nextContentType = updateContentType({
inputs: createInputs(),
id: 'node-1',
contentType: 'text/plain',
isVarUsedInNodes: () => true,
removeUsedVarInNodes,
})
expect(nextContentType.body).toEqual([])
expect(nextContentType.variables.every(item => item.label !== 'body')).toBe(true)
expect(removeUsedVarInNodes).toHaveBeenCalledWith(['node-1', 'body_value'])
expect(updateContentType({
inputs: createInputs(),
id: 'node-1',
contentType: 'application/json',
isVarUsedInNodes: () => false,
removeUsedVarInNodes,
}).body).toEqual([])
expect(updateContentType({
inputs: {
...createInputs(),
variables: undefined,
} as unknown as WebhookTriggerNodeType,
id: 'node-1',
contentType: 'multipart/form-data',
isVarUsedInNodes: () => false,
removeUsedVarInNodes,
}).body).toEqual([])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'param',
nextData: [{ name: 'page', type: VarType.number, required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).params).toEqual([{ name: 'page', type: VarType.number, required: true }])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'body',
nextData: [{ name: 'payload', type: VarType.string, required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).body).toEqual([{ name: 'payload', type: VarType.string, required: true }])
expect(updateSourceFields({
inputs: createInputs(),
id: 'node-1',
sourceType: 'header',
nextData: [{ name: 'x-request-id', required: true }],
notifyError: vi.fn(),
isVarUsedInNodes: () => false,
removeUsedVarInNodes: vi.fn(),
}).headers).toEqual([{ name: 'x-request-id', required: true }])
expect(updateMethod(createInputs(), 'GET').method).toBe('GET')
expect(updateSimpleField(createInputs(), 'status_code', 204).status_code).toBe(204)
expect(updateWebhookUrls(createInputs(), 'https://hook', 'https://debug')).toEqual(expect.objectContaining({
webhook_url: 'https://hook',
webhook_debug_url: 'https://debug',
}))
})
})

View File

@ -0,0 +1,207 @@
import type { WebhookTriggerNodeType } from '../types'
import { renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { fetchWebhookUrl } from '@/service/apps'
import { createNodeCrudModuleMock } from '../../__tests__/use-config-test-utils'
import { DEFAULT_STATUS_CODE, MAX_STATUS_CODE, normalizeStatusCode, useConfig } from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn())
const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => options?.key || key,
}),
}))
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: vi.fn(),
},
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
useWorkflow: () => ({
isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args),
removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<WebhookTriggerNodeType>(mockSetInputs),
}))
vi.mock('@/service/apps', () => ({
fetchWebhookUrl: vi.fn(),
}))
const mockedFetchWebhookUrl = vi.mocked(fetchWebhookUrl)
const mockedToastNotify = vi.mocked(Toast.notify)
const createPayload = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({
title: 'Webhook',
desc: '',
type: BlockEnum.TriggerWebhook,
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: false,
status_code: 200,
response_body: '',
variables: [],
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(useAppStore, 'getState').mockReturnValue({
appDetail: { id: 'app-1' },
} as never)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockIsVarUsedInNodes.mockReturnValue(false)
})
it('should update simple fields and reset body variables when content type changes', () => {
const payload = createPayload({
content_type: 'application/json',
body: [{ name: 'payload', type: VarType.string, required: true }],
variables: [
{ variable: 'payload', label: 'body', required: true, value_selector: [], value_type: VarType.string },
{ variable: 'token', label: 'header', required: false, value_selector: [], value_type: VarType.string },
],
})
mockIsVarUsedInNodes.mockImplementation(([_, variable]) => variable === 'payload')
const { result } = renderHook(() => useConfig('webhook-node', payload))
result.current.handleMethodChange('GET')
result.current.handleContentTypeChange('text/plain')
result.current.handleAsyncModeChange(true)
result.current.handleStatusCodeChange(204)
result.current.handleResponseBodyChange('ok')
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
method: 'GET',
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
content_type: 'text/plain',
body: [],
variables: [
expect.objectContaining({
variable: 'token',
label: 'header',
}),
],
}))
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['webhook-node', 'payload'])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ async_mode: true }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ status_code: 204 }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ response_body: 'ok' }))
})
it('should sync params, headers and body variables and reject conflicting names', () => {
const payload = createPayload({
variables: [
{ variable: 'existing_header', label: 'header', required: false, value_selector: [], value_type: VarType.string },
],
})
const { result } = renderHook(() => useConfig('webhook-node', payload))
result.current.handleParamsChange([{ name: 'page', type: VarType.number, required: true }])
result.current.handleHeadersChange([{ name: 'x-request-id', required: false }])
result.current.handleBodyChange([{ name: 'body_field', type: VarType.string, required: true }])
result.current.handleParamsChange([{ name: 'existing_header', type: VarType.string, required: true }])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
params: [{ name: 'page', type: VarType.number, required: true }],
variables: expect.arrayContaining([
expect.objectContaining({
variable: 'page',
label: 'param',
value_type: VarType.number,
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
headers: [{ name: 'x-request-id', required: false }],
variables: expect.arrayContaining([
expect.objectContaining({
variable: 'x_request_id',
label: 'header',
}),
]),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
body: [{ name: 'body_field', type: VarType.string, required: true }],
variables: expect.arrayContaining([
expect.objectContaining({
variable: 'body_field',
label: 'body',
}),
]),
}))
expect(mockedToastNotify).toHaveBeenCalledTimes(1)
})
it('should generate webhook urls once and fall back to empty url on request failure', async () => {
mockedFetchWebhookUrl.mockResolvedValueOnce({
webhook_url: 'https://example.com/hook',
webhook_debug_url: 'https://example.com/debug',
} as never)
mockedFetchWebhookUrl.mockRejectedValueOnce(new Error('boom'))
const { result, rerender } = renderHook(({ payload }) => useConfig('webhook-node', payload), {
initialProps: {
payload: createPayload(),
},
})
await result.current.generateWebhookUrl()
expect(mockedFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-1', nodeId: 'webhook-node' })
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
webhook_url: 'https://example.com/hook',
webhook_debug_url: 'https://example.com/debug',
}))
rerender({
payload: createPayload(),
})
await result.current.generateWebhookUrl()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
webhook_url: '',
}))
rerender({
payload: createPayload({ webhook_url: 'https://already-exists' }),
})
await result.current.generateWebhookUrl()
expect(mockedFetchWebhookUrl).toHaveBeenCalledTimes(2)
})
it('should expose readonly state, clamp status codes and skip url generation without app id', async () => {
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
vi.spyOn(useAppStore, 'getState').mockReturnValue({
appDetail: undefined,
} as never)
const { result } = renderHook(() => useConfig('webhook-node', createPayload()))
expect(result.current.readOnly).toBe(true)
expect(normalizeStatusCode(DEFAULT_STATUS_CODE - 10)).toBe(DEFAULT_STATUS_CODE)
expect(normalizeStatusCode(248)).toBe(248)
expect(normalizeStatusCode(MAX_STATUS_CODE + 10)).toBe(MAX_STATUS_CODE)
await result.current.generateWebhookUrl()
expect(mockedFetchWebhookUrl).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,197 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import GenericTable from '../generic-table'
const columns = [
{
key: 'name',
title: 'Name',
type: 'input' as const,
placeholder: 'Name',
width: 'w-[140px]',
},
{
key: 'enabled',
title: 'Enabled',
type: 'switch' as const,
width: 'w-[80px]',
},
]
const advancedColumns = [
{
key: 'method',
title: 'Method',
type: 'select' as const,
placeholder: 'Choose method',
options: [{ name: 'POST', value: 'post' }],
width: 'w-[120px]',
},
{
key: 'preview',
title: 'Preview',
type: 'custom' as const,
width: 'w-[120px]',
render: (_value: unknown, row: { method?: string }, index: number, onChange: (value: unknown) => void) => (
<button type="button" onClick={() => onChange(`${index}:${row.method || 'empty'}`)}>
custom-render
</button>
),
},
{
key: 'unsupported',
title: 'Unsupported',
type: 'unsupported' as never,
width: 'w-[80px]',
},
]
describe('GenericTable', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
const onChange = vi.fn()
render(
<GenericTable
title="Headers"
columns={columns}
data={[]}
emptyRowData={{ name: '', enabled: false }}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'my key' } })
expect(onChange).toHaveBeenLastCalledWith([{ name: 'my_key', enabled: false }])
})
it('should skip intermediate empty rows and blur the current input when enter is pressed', () => {
render(
<GenericTable
title="Headers"
columns={columns}
data={[
{ name: 'alpha', enabled: false },
{ name: '', enabled: false },
{ name: 'beta', enabled: true },
]}
emptyRowData={{ name: '', enabled: false }}
onChange={vi.fn()}
/>,
)
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(3)
expect(screen.getAllByRole('button', { name: 'Delete row' })).toHaveLength(2)
const blurSpy = vi.spyOn(inputs[0], 'blur')
fireEvent.keyDown(inputs[0], { key: 'Enter' })
expect(blurSpy).toHaveBeenCalledTimes(1)
})
it('should update existing rows, show delete action, and remove rows by primary key', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<GenericTable
title="Headers"
columns={columns}
data={[{ name: 'alpha', enabled: false }]}
emptyRowData={{ name: '', enabled: false }}
onChange={onChange}
showHeader
/>,
)
expect(screen.getByText('Name')).toBeInTheDocument()
await user.click(screen.getAllByRole('checkbox')[0])
expect(onChange).toHaveBeenCalledWith([{ name: 'alpha', enabled: true }])
await user.click(screen.getByRole('button', { name: 'Delete row' }))
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should update select and custom cells for existing rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const ControlledTable = () => {
const [data, setData] = useState([{ method: '', preview: '' }])
return (
<GenericTable
title="Advanced"
columns={advancedColumns}
data={data}
emptyRowData={{ method: '', preview: '' }}
onChange={(nextData) => {
onChange(nextData)
setData(nextData as { method: string, preview: string }[])
}}
/>
)
}
render(
<ControlledTable />,
)
await user.click(screen.getByRole('button', { name: 'Choose method' }))
await user.click(await screen.findByText('POST'))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
})
onChange.mockClear()
await user.click(screen.getAllByRole('button', { name: 'custom-render' })[0])
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '0:post' }])
})
})
it('should ignore custom-cell updates when readonly rows are rendered', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<GenericTable
title="Advanced"
columns={advancedColumns}
data={[{ method: 'post', preview: '' }]}
emptyRowData={{ method: '', preview: '' }}
onChange={onChange}
readonly
/>,
)
await user.click(screen.getByRole('button', { name: 'custom-render' }))
expect(onChange).not.toHaveBeenCalled()
})
it('should show readonly placeholder without rendering editable rows', () => {
render(
<GenericTable
title="Headers"
columns={columns}
data={[]}
emptyRowData={{ name: '', enabled: false }}
onChange={vi.fn()}
readonly
placeholder="No data"
/>,
)
expect(screen.getByText('No data')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})

View File

@ -57,6 +57,126 @@ type DisplayRow = {
isVirtual: boolean // whether this row is the extra empty row for adding new items
}
const isEmptyRow = (row: GenericTableRow) => {
return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false)
}
const getDisplayRows = (
data: GenericTableRow[],
emptyRowData: GenericTableRow,
readonly: boolean,
): DisplayRow[] => {
if (readonly)
return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false }))
if (!data.length)
return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }]
const rows = data.reduce<DisplayRow[]>((acc, row, index) => {
if (isEmptyRow(row) && index < data.length - 1)
return acc
acc.push({ row, dataIndex: index, isVirtual: false })
return acc
}, [])
const lastRow = data.at(-1)
if (lastRow && !isEmptyRow(lastRow))
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
return rows
}
const getPrimaryKey = (columns: ColumnConfig[]) => {
return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
}
const renderInputCell = (
column: ColumnConfig,
value: unknown,
readonly: boolean,
handleChange: (value: unknown) => void,
) => {
return (
<Input
value={(value as string) || ''}
onChange={(e) => {
if (column.key === 'key' || column.key === 'name')
replaceSpaceWithUnderscoreInVarNameInput(e.target)
handleChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}}
placeholder={column.placeholder}
disabled={readonly}
wrapperClassName="w-full min-w-0"
className={cn(
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
)}
/>
)
}
const renderSelectCell = (
column: ColumnConfig,
value: unknown,
readonly: boolean,
handleChange: (value: unknown) => void,
) => {
return (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => handleChange(item.value)}
disabled={readonly}
placeholder={column.placeholder}
hideChecked={false}
notClearable={true}
wrapperClassName="h-6 w-full min-w-0"
className={cn(
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
)}
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
/>
)
}
const renderSwitchCell = (
column: ColumnConfig,
value: unknown,
dataIndex: number | null,
readonly: boolean,
handleChange: (value: unknown) => void,
) => {
return (
<div className="flex h-7 items-center">
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
/>
</div>
)
}
const renderCustomCell = (
column: ColumnConfig,
value: unknown,
row: GenericTableRow,
dataIndex: number | null,
handleChange: (value: unknown) => void,
) => {
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
}
const GenericTable: FC<GenericTableProps> = ({
title,
columns,
@ -68,42 +188,8 @@ const GenericTable: FC<GenericTableProps> = ({
className,
showHeader = false,
}) => {
// Build the rows to display while keeping a stable mapping to original data
const displayRows = useMemo<DisplayRow[]>(() => {
// Helper to check empty
const isEmptyRow = (r: GenericTableRow) =>
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
if (readonly)
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
const hasData = data.length > 0
const rows: DisplayRow[] = []
if (!hasData) {
// Initialize with exactly one empty row when there is no data
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
return rows
}
// Add configured rows, hide intermediate empty ones, keep mapping
data.forEach((r, i) => {
const isEmpty = isEmptyRow(r)
// Skip empty rows except the very last configured row
if (isEmpty && i < data.length - 1)
return
rows.push({ row: r, dataIndex: i, isVirtual: false })
})
// If the last configured row has content, append a trailing empty row
const lastRow = data.at(-1)
if (!lastRow)
return rows
const lastHasContent = !isEmptyRow(lastRow)
if (lastHasContent)
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
return rows
return getDisplayRows(data, emptyRowData, readonly)
}, [data, emptyRowData, readonly])
const removeRow = useCallback((dataIndex: number) => {
@ -134,9 +220,7 @@ const GenericTable: FC<GenericTableProps> = ({
}, [data, emptyRowData, onChange, readonly])
// Determine the primary identifier column just once
const primaryKey = useMemo(() => (
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
), [columns])
const primaryKey = useMemo(() => getPrimaryKey(columns), [columns])
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
const value = row[column.key]
@ -144,67 +228,16 @@ const GenericTable: FC<GenericTableProps> = ({
switch (column.type) {
case 'input':
return (
<Input
value={(value as string) || ''}
onChange={(e) => {
// Format variable names (replace spaces with underscores)
if (column.key === 'key' || column.key === 'name')
replaceSpaceWithUnderscoreInVarNameInput(e.target)
handleChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}}
placeholder={column.placeholder}
disabled={readonly}
wrapperClassName="w-full min-w-0"
className={cn(
// Ghost/inline style: looks like plain text until focus/hover
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
)}
/>
)
return renderInputCell(column, value, readonly, handleChange)
case 'select':
return (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => handleChange(item.value)}
disabled={readonly}
placeholder={column.placeholder}
hideChecked={false}
notClearable={true}
// wrapper provides compact height, trigger is transparent like text
wrapperClassName="h-6 w-full min-w-0"
className={cn(
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
)}
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
/>
)
return renderSelectCell(column, value, readonly, handleChange)
case 'switch':
return (
<div className="flex h-7 items-center">
<Checkbox
id={`${column.key}-${String(dataIndex ?? 'v')}`}
checked={Boolean(value)}
onCheck={() => handleChange(!value)}
disabled={readonly}
/>
</div>
)
return renderSwitchCell(column, value, dataIndex, readonly, handleChange)
case 'custom':
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
return renderCustomCell(column, value, row, dataIndex, handleChange)
default:
return null
@ -270,6 +303,7 @@ const GenericTable: FC<GenericTableProps> = ({
className="p-1"
aria-label="Delete row"
>
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
</button>
</div>

View File

@ -0,0 +1,220 @@
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import type { Variable } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { VarType } from '@/app/components/workflow/types'
import { checkKeys, hasDuplicateStr } from '@/utils/var'
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
export type VariableSyncSource = 'param' | 'header' | 'body'
type SanitizedEntry = {
item: WebhookParameter | WebhookHeader
sanitizedName: string
}
type NotifyError = (key: string) => void
const sanitizeEntryName = (item: WebhookParameter | WebhookHeader, sourceType: VariableSyncSource) => {
return sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name
}
const getSanitizedEntries = (
newData: (WebhookParameter | WebhookHeader)[],
sourceType: VariableSyncSource,
): SanitizedEntry[] => {
return newData.map(item => ({
item,
sanitizedName: sanitizeEntryName(item, sourceType),
}))
}
const createVariable = (
item: WebhookParameter | WebhookHeader,
sourceType: VariableSyncSource,
sanitizedName: string,
): Variable => {
const inputVarType: VarType = 'type' in item ? item.type : VarType.string
return {
value_type: inputVarType,
label: sourceType,
variable: sanitizedName,
value_selector: [],
required: item.required,
}
}
export const syncVariables = ({
draft,
id,
newData,
sourceType,
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
}: {
draft: WebhookTriggerNodeType
id: string
newData: (WebhookParameter | WebhookHeader)[]
sourceType: VariableSyncSource
notifyError: NotifyError
isVarUsedInNodes: (selector: [string, string]) => boolean
removeUsedVarInNodes: (selector: [string, string]) => void
}) => {
if (!draft.variables)
draft.variables = []
const sanitizedEntries = getSanitizedEntries(newData, sourceType)
if (sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)) {
notifyError('variableConfig.varName')
return false
}
const existingOtherVarNames = new Set(
draft.variables
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
.map(v => v.variable),
)
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
if (crossScopeConflict) {
notifyError(crossScopeConflict.sanitizedName)
return false
}
if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
notifyError('variableConfig.varName')
return false
}
for (const { sanitizedName } of sanitizedEntries) {
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
if (!isValid) {
notifyError(`varKeyError.${errorMessageKey}`)
return false
}
}
const nextNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
draft.variables
.filter(v => v.label === sourceType && !nextNames.has(v.variable))
.forEach((variable) => {
if (isVarUsedInNodes([id, variable.variable]))
removeUsedVarInNodes([id, variable.variable])
})
draft.variables = draft.variables.filter((variable) => {
if (variable.label !== sourceType)
return true
return nextNames.has(variable.variable)
})
sanitizedEntries.forEach(({ item, sanitizedName }) => {
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
const variable = createVariable(item, sourceType, sanitizedName)
if (existingVarIndex >= 0)
draft.variables[existingVarIndex] = variable
else
draft.variables.push(variable)
})
return true
}
export const updateMethod = (inputs: WebhookTriggerNodeType, method: HttpMethod) => produce(inputs, (draft) => {
draft.method = method
})
export const updateSimpleField = <
K extends 'async_mode' | 'status_code' | 'response_body',
>(
inputs: WebhookTriggerNodeType,
key: K,
value: WebhookTriggerNodeType[K],
) => produce(inputs, (draft) => {
draft[key] = value
})
export const updateContentType = ({
inputs,
id,
contentType,
isVarUsedInNodes,
removeUsedVarInNodes,
}: {
inputs: WebhookTriggerNodeType
id: string
contentType: string
isVarUsedInNodes: (selector: [string, string]) => boolean
removeUsedVarInNodes: (selector: [string, string]) => void
}) => produce(inputs, (draft) => {
const previousContentType = draft.content_type
draft.content_type = contentType
if (previousContentType === contentType)
return
draft.body = []
if (!draft.variables)
return
draft.variables
.filter(v => v.label === 'body')
.forEach((variable) => {
if (isVarUsedInNodes([id, variable.variable]))
removeUsedVarInNodes([id, variable.variable])
})
draft.variables = draft.variables.filter(v => v.label !== 'body')
})
type SourceField = 'params' | 'headers' | 'body'
const getSourceField = (sourceType: VariableSyncSource): SourceField => {
switch (sourceType) {
case 'param':
return 'params'
case 'header':
return 'headers'
default:
return 'body'
}
}
export const updateSourceFields = ({
inputs,
id,
sourceType,
nextData,
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
}: {
inputs: WebhookTriggerNodeType
id: string
sourceType: VariableSyncSource
nextData: WebhookParameter[] | WebhookHeader[]
notifyError: NotifyError
isVarUsedInNodes: (selector: [string, string]) => boolean
removeUsedVarInNodes: (selector: [string, string]) => void
}) => produce(inputs, (draft) => {
draft[getSourceField(sourceType)] = nextData as never
syncVariables({
draft,
id,
newData: nextData,
sourceType,
notifyError,
isVarUsedInNodes,
removeUsedVarInNodes,
})
})
export const updateWebhookUrls = (
inputs: WebhookTriggerNodeType,
webhookUrl: string,
webhookDebugUrl?: string,
) => produce(inputs, (draft) => {
draft.webhook_url = webhookUrl
draft.webhook_debug_url = webhookDebugUrl
})

View File

@ -1,17 +1,18 @@
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import type { Variable } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { VarType } from '@/app/components/workflow/types'
import { fetchWebhookUrl } from '@/service/apps'
import { checkKeys, hasDuplicateStr } from '@/utils/var'
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
import {
updateContentType,
updateMethod,
updateSimpleField,
updateSourceFields,
updateWebhookUrls,
} from './use-config.helpers'
export const DEFAULT_STATUS_CODE = 200
export const MAX_STATUS_CODE = 399
@ -24,182 +25,80 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const appId = useAppStore.getState().appDetail?.id
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
const notifyVarError = useCallback((key: string) => {
const fieldLabel = key === 'variableConfig.varName'
? t('variableConfig.varName', { ns: 'appDebug' })
: key
const message = key.startsWith('varKeyError.')
? t(key as never, { ns: 'appDebug', key: fieldLabel })
: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: fieldLabel })
Toast.notify({
type: 'error',
message,
})
}, [t])
const handleMethodChange = useCallback((method: HttpMethod) => {
setInputs(produce(inputs, (draft) => {
draft.method = method
}))
setInputs(updateMethod(inputs, method))
}, [inputs, setInputs])
const handleContentTypeChange = useCallback((contentType: string) => {
setInputs(produce(inputs, (draft) => {
const previousContentType = draft.content_type
draft.content_type = contentType
// If the content type changes, reset body parameters and their variables, as the variable types might differ.
// However, we could consider retaining variables that are compatible with the new content type later.
if (previousContentType !== contentType) {
draft.body = []
if (draft.variables) {
const bodyVariables = draft.variables.filter(v => v.label === 'body')
bodyVariables.forEach((v) => {
if (isVarUsedInNodes([id, v.variable]))
removeUsedVarInNodes([id, v.variable])
})
draft.variables = draft.variables.filter(v => v.label !== 'body')
}
}
setInputs(updateContentType({
inputs,
id,
contentType,
isVarUsedInNodes,
removeUsedVarInNodes,
}))
}, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes])
const syncVariablesInDraft = useCallback((
draft: WebhookTriggerNodeType,
newData: (WebhookParameter | WebhookHeader)[],
sourceType: 'param' | 'header' | 'body',
) => {
if (!draft.variables)
draft.variables = []
const sanitizedEntries = newData.map(item => ({
item,
sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name,
}))
const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)
if (hasReservedConflict) {
Toast.notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', {
ns: 'appDebug',
key: t('variableConfig.varName', { ns: 'appDebug' }),
}),
})
return false
}
const existingOtherVarNames = new Set(
draft.variables
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
.map(v => v.variable),
)
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
if (crossScopeConflict) {
Toast.notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', {
ns: 'appDebug',
key: crossScopeConflict.sanitizedName,
}),
})
return false
}
if (hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
Toast.notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', {
ns: 'appDebug',
key: t('variableConfig.varName', { ns: 'appDebug' }),
}),
})
return false
}
for (const { sanitizedName } of sanitizedEntries) {
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, {
ns: 'appDebug',
key: t('variableConfig.varName', { ns: 'appDebug' }),
}),
})
return false
}
}
// Create set of new variable names for this source
const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
// Find variables from current source that will be deleted and clean up references
draft.variables
.filter(v => v.label === sourceType && !newVarNames.has(v.variable))
.forEach((v) => {
// Clean up references if variable is used in other nodes
if (isVarUsedInNodes([id, v.variable]))
removeUsedVarInNodes([id, v.variable])
})
// Remove variables that no longer exist in newData for this specific source type
draft.variables = draft.variables.filter((v) => {
// Keep variables from other sources
if (v.label !== sourceType)
return true
return newVarNames.has(v.variable)
})
// Add or update variables
sanitizedEntries.forEach(({ item, sanitizedName }) => {
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
const inputVarType = 'type' in item
? item.type
: VarType.string // Default to string for headers
const newVar: Variable = {
value_type: inputVarType,
label: sourceType, // Use sourceType as label to identify source
variable: sanitizedName,
value_selector: [],
required: item.required,
}
if (existingVarIndex >= 0)
draft.variables[existingVarIndex] = newVar
else
draft.variables.push(newVar)
})
return true
}, [t, id, isVarUsedInNodes, removeUsedVarInNodes])
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.params = params
syncVariablesInDraft(draft, params, 'param')
setInputs(updateSourceFields({
inputs,
id,
sourceType: 'param',
nextData: params,
notifyError: notifyVarError,
isVarUsedInNodes,
removeUsedVarInNodes,
}))
}, [inputs, setInputs, syncVariablesInDraft])
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
setInputs(produce(inputs, (draft) => {
draft.headers = headers
syncVariablesInDraft(draft, headers, 'header')
setInputs(updateSourceFields({
inputs,
id,
sourceType: 'header',
nextData: headers,
notifyError: notifyVarError,
isVarUsedInNodes,
removeUsedVarInNodes,
}))
}, [inputs, setInputs, syncVariablesInDraft])
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.body = body
syncVariablesInDraft(draft, body, 'body')
setInputs(updateSourceFields({
inputs,
id,
sourceType: 'body',
nextData: body,
notifyError: notifyVarError,
isVarUsedInNodes,
removeUsedVarInNodes,
}))
}, [inputs, setInputs, syncVariablesInDraft])
}, [id, inputs, isVarUsedInNodes, notifyVarError, removeUsedVarInNodes, setInputs])
const handleAsyncModeChange = useCallback((asyncMode: boolean) => {
setInputs(produce(inputs, (draft) => {
draft.async_mode = asyncMode
}))
setInputs(updateSimpleField(inputs, 'async_mode', asyncMode))
}, [inputs, setInputs])
const handleStatusCodeChange = useCallback((statusCode: number) => {
setInputs(produce(inputs, (draft) => {
draft.status_code = statusCode
}))
setInputs(updateSimpleField(inputs, 'status_code', statusCode))
}, [inputs, setInputs])
const handleResponseBodyChange = useCallback((responseBody: string) => {
setInputs(produce(inputs, (draft) => {
draft.response_body = responseBody
}))
setInputs(updateSimpleField(inputs, 'response_body', responseBody))
}, [inputs, setInputs])
const generateWebhookUrl = useCallback(async () => {
@ -211,23 +110,12 @@ export const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
return
try {
// Call backend to generate or fetch webhook url for this node
const response = await fetchWebhookUrl({ appId, nodeId: id })
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = response.webhook_url
draft.webhook_debug_url = response.webhook_debug_url
})
setInputs(newInputs)
setInputs(updateWebhookUrls(inputs, response.webhook_url, response.webhook_debug_url))
}
catch (error: unknown) {
// Fallback to mock URL when API is not ready or request fails
// Keep the UI unblocked and allow users to proceed in local/dev environments.
console.error('Failed to generate webhook URL:', error)
const newInputs = produce(inputs, (draft) => {
draft.webhook_url = ''
})
setInputs(newInputs)
setInputs(updateWebhookUrls(inputs, ''))
}
}, [appId, id, inputs, setInputs])

View File

@ -0,0 +1,255 @@
import type { VariableAssignerNodeType } from '../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
createNodeCrudModuleMock,
createUuidModuleMock,
} from '../../__tests__/use-config-test-utils'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockDeleteNodeInspectorVars = vi.hoisted(() => vi.fn())
const mockRenameInspectVarName = vi.hoisted(() => vi.fn())
const mockHandleOutVarRenameChange = vi.hoisted(() => vi.fn())
const mockIsVarUsedInNodes = vi.hoisted(() => vi.fn())
const mockRemoveUsedVarInNodes = vi.hoisted(() => vi.fn())
const mockGetAvailableVars = vi.hoisted(() => vi.fn())
const mockUuid = vi.hoisted(() => vi.fn(() => 'generated-group-id'))
vi.mock('uuid', () => ({
...createUuidModuleMock(mockUuid),
}))
vi.mock('ahooks', () => ({
useBoolean: (initialValue: boolean) => {
let current = initialValue
return [
current,
{
setTrue: () => {
current = true
},
setFalse: () => {
current = false
},
},
] as const
},
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
run: fn,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useWorkflow: () => ({
handleOutVarRenameChange: (...args: unknown[]) => mockHandleOutVarRenameChange(...args),
isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args),
removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
...createNodeCrudModuleMock<VariableAssignerNodeType>(mockSetInputs),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
__esModule: true,
default: () => ({
deleteNodeInspectorVars: (...args: unknown[]) => mockDeleteNodeInspectorVars(...args),
renameInspectVarName: (...args: unknown[]) => mockRenameInspectVarName(...args),
}),
}))
vi.mock('../hooks', () => ({
useGetAvailableVars: () => mockGetAvailableVars,
}))
const createPayload = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({
title: 'Variable Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
advanced_settings: {
group_enabled: true,
groups: [
{
groupId: 'group-1',
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
},
{
groupId: 'group-2',
group_name: 'Group2',
output_type: VarType.number,
variables: [],
},
],
},
...overrides,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAvailableVars.mockReturnValue([])
mockIsVarUsedInNodes.mockReturnValue(false)
})
it('should expose read-only state, group mode and typed variable filters', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
expect(result.current.readOnly).toBe(false)
expect(result.current.isEnableGroup).toBe(true)
expect(result.current.filterVar(VarType.string)({ type: VarType.any } as never)).toBe(true)
expect(result.current.filterVar(VarType.number)({ type: VarType.string } as never)).toBe(false)
expect(result.current.getAvailableVars).toBe(mockGetAvailableVars)
})
it('should update root and grouped variable payloads', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
result.current.handleListOrTypeChange({
output_type: VarType.number,
variables: [['source-node', 'changed']],
})
result.current.handleListOrTypeChangeInGroup('group-1')({
output_type: VarType.boolean,
variables: [['source-node', 'groupVar']],
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
output_type: VarType.number,
variables: [['source-node', 'changed']],
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
groups: [
expect.objectContaining({
groupId: 'group-1',
output_type: VarType.boolean,
variables: [['source-node', 'groupVar']],
}),
expect.anything(),
],
}),
}))
})
it('should add and remove groups and toggle group mode', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
result.current.handleAddGroup()
result.current.handleGroupRemoved('group-2')()
result.current.handleGroupEnabledChange(false)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
groups: expect.arrayContaining([
expect.objectContaining({
groupId: 'generated-group-id',
group_name: 'Group3',
}),
]),
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
groups: [
expect.objectContaining({ groupId: 'group-1' }),
],
}),
}))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
group_enabled: false,
}),
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node')
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
'assigner-node',
['assigner-node', 'Group1', 'output'],
['assigner-node', 'output'],
)
})
it('should rename groups and remove used vars after confirmation', () => {
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
result.current.handleVarGroupNameChange('group-1')('Renamed')
result.current.onRemoveVarConfirm()
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
groups: [
expect.objectContaining({
groupId: 'group-1',
group_name: 'Renamed',
}),
expect.anything(),
],
}),
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
'assigner-node',
['assigner-node', 'Group1', 'output'],
['assigner-node', 'Renamed', 'output'],
)
expect(mockRenameInspectVarName).toHaveBeenCalledWith('assigner-node', 'Group1', 'Renamed')
})
it('should confirm removing a used group before deleting it', () => {
mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2')
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
act(() => {
result.current.handleGroupRemoved('group-2')()
})
act(() => {
result.current.onRemoveVarConfirm()
})
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output'])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({
groups: [expect.objectContaining({ groupId: 'group-1' })],
}),
}))
})
it('should enable empty groups and confirm disabling when downstream vars are used', () => {
const { result: enableResult } = renderHook(() => useConfig('assigner-node', createPayload({
advanced_settings: {
group_enabled: false,
groups: [],
},
})))
enableResult.current.handleGroupEnabledChange(true)
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
'assigner-node',
['assigner-node', 'output'],
['assigner-node', 'Group1', 'output'],
)
mockIsVarUsedInNodes.mockImplementation(selector => selector[1] === 'Group2')
const { result } = renderHook(() => useConfig('assigner-node', createPayload()))
act(() => {
result.current.handleGroupEnabledChange(false)
})
act(() => {
result.current.onRemoveVarConfirm()
})
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['assigner-node', 'Group2', 'output'])
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
advanced_settings: expect.objectContaining({ group_enabled: false }),
}))
})
})

View File

@ -0,0 +1,99 @@
import type { Var } from '../../types'
import type { VarGroupItem, VariableAssignerNodeType } from './types'
import { produce } from 'immer'
import { v4 as uuid4 } from 'uuid'
import { VarType } from '../../types'
export const filterVarByType = (varType: VarType) => {
return (variable: Var) => {
if (varType === VarType.any || variable.type === VarType.any)
return true
return variable.type === varType
}
}
export const updateRootVarGroupItem = (
inputs: VariableAssignerNodeType,
payload: VarGroupItem,
) => ({
...inputs,
...payload,
})
export const updateNestedVarGroupItem = (
inputs: VariableAssignerNodeType,
groupId: string,
payload: VarGroupItem,
) => produce(inputs, (draft) => {
const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId)
draft.advanced_settings.groups[index] = {
...draft.advanced_settings.groups[index],
...payload,
}
})
export const removeGroupByIndex = (
inputs: VariableAssignerNodeType,
index: number,
) => produce(inputs, (draft) => {
draft.advanced_settings.groups.splice(index, 1)
})
export const toggleGroupEnabled = ({
inputs,
enabled,
}: {
inputs: VariableAssignerNodeType
enabled: boolean
}) => produce(inputs, (draft) => {
if (!draft.advanced_settings)
draft.advanced_settings = { group_enabled: false, groups: [] }
if (enabled) {
if (draft.advanced_settings.groups.length === 0) {
draft.advanced_settings.groups = [{
output_type: draft.output_type,
variables: draft.variables,
group_name: 'Group1',
groupId: uuid4(),
}]
}
}
else if (draft.advanced_settings.groups.length > 0) {
draft.output_type = draft.advanced_settings.groups[0].output_type
draft.variables = draft.advanced_settings.groups[0].variables
}
draft.advanced_settings.group_enabled = enabled
})
export const addGroup = (inputs: VariableAssignerNodeType) => {
let maxInGroupName = 1
inputs.advanced_settings.groups.forEach((item) => {
const match = /(\d+)$/.exec(item.group_name)
if (match) {
const num = Number.parseInt(match[1], 10)
if (num > maxInGroupName)
maxInGroupName = num
}
})
return produce(inputs, (draft) => {
draft.advanced_settings.groups.push({
output_type: VarType.any,
variables: [],
group_name: `Group${maxInGroupName + 1}`,
groupId: uuid4(),
})
})
}
export const renameGroup = (
inputs: VariableAssignerNodeType,
groupId: string,
name: string,
) => produce(inputs, (draft) => {
const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId)
draft.advanced_settings.groups[index].group_name = name
})

View File

@ -1,9 +1,7 @@
import type { ValueSelector, Var } from '../../types'
import type { ValueSelector } from '../../types'
import type { VarGroupItem, VariableAssignerNodeType } from './types'
import { useBoolean, useDebounceFn } from 'ahooks'
import { produce } from 'immer'
import { useCallback, useRef, useState } from 'react'
import { v4 as uuid4 } from 'uuid'
import {
useNodesReadOnly,
useWorkflow,
@ -11,8 +9,16 @@ import {
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
import { VarType } from '../../types'
import { useGetAvailableVars } from './hooks'
import {
addGroup,
filterVarByType,
removeGroupByIndex,
renameGroup,
toggleGroupEnabled,
updateNestedVarGroupItem,
updateRootVarGroupItem,
} from './use-config.helpers'
const useConfig = (id: string, payload: VariableAssignerNodeType) => {
const {
@ -27,35 +33,16 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
// Not Enable Group
const handleListOrTypeChange = useCallback((payload: VarGroupItem) => {
setInputs({
...inputs,
...payload,
})
setInputs(updateRootVarGroupItem(inputs, payload))
}, [inputs, setInputs])
const handleListOrTypeChangeInGroup = useCallback((groupId: string) => {
return (payload: VarGroupItem) => {
const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId)
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.groups[index] = {
...draft.advanced_settings.groups[index],
...payload,
}
})
setInputs(newInputs)
setInputs(updateNestedVarGroupItem(inputs, groupId, payload))
}
}, [inputs, setInputs])
const getAvailableVars = useGetAvailableVars()
const filterVar = (varType: VarType) => {
return (v: Var) => {
if (varType === VarType.any)
return true
if (v.type === VarType.any)
return true
return v.type === varType
}
}
const [isShowRemoveVarConfirm, {
setTrue: showRemoveVarConfirm,
@ -75,84 +62,48 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
setRemovedGroupIndex(index)
return
}
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.groups.splice(index, 1)
})
setInputs(newInputs)
setInputs(removeGroupByIndex(inputs, index))
}
}, [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm])
const handleGroupEnabledChange = useCallback((enabled: boolean) => {
const newInputs = produce(inputs, (draft) => {
if (!draft.advanced_settings)
draft.advanced_settings = { group_enabled: false, groups: [] }
if (enabled) {
if (draft.advanced_settings.groups.length === 0) {
const DEFAULT_GROUP_NAME = 'Group1'
draft.advanced_settings.groups = [{
output_type: draft.output_type,
variables: draft.variables,
group_name: DEFAULT_GROUP_NAME,
groupId: uuid4(),
}]
if (enabled && inputs.advanced_settings.groups.length === 0) {
handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output'])
}
handleOutVarRenameChange(id, [id, 'output'], [id, DEFAULT_GROUP_NAME, 'output'])
if (!enabled && inputs.advanced_settings.groups.length > 0) {
if (inputs.advanced_settings.groups.length > 1) {
const useVars = inputs.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output']))
if (useVars.length > 0) {
showRemoveVarConfirm()
setRemovedVars(useVars.map(item => [id, item.group_name, 'output']))
setRemoveType('enableChanged')
return
}
}
else {
if (draft.advanced_settings.groups.length > 0) {
if (draft.advanced_settings.groups.length > 1) {
const useVars = draft.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output']))
if (useVars.length > 0) {
showRemoveVarConfirm()
setRemovedVars(useVars.map(item => [id, item.group_name, 'output']))
setRemoveType('enableChanged')
return
}
}
draft.output_type = draft.advanced_settings.groups[0].output_type
draft.variables = draft.advanced_settings.groups[0].variables
handleOutVarRenameChange(id, [id, draft.advanced_settings.groups[0].group_name, 'output'], [id, 'output'])
}
}
draft.advanced_settings.group_enabled = enabled
})
setInputs(newInputs)
handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[0].group_name, 'output'], [id, 'output'])
}
setInputs(toggleGroupEnabled({ inputs, enabled }))
deleteNodeInspectorVars(id)
}, [deleteNodeInspectorVars, handleOutVarRenameChange, id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm])
const handleAddGroup = useCallback(() => {
let maxInGroupName = 1
inputs.advanced_settings.groups.forEach((item) => {
const match = /(\d+)$/.exec(item.group_name)
if (match) {
const num = Number.parseInt(match[1], 10)
if (num > maxInGroupName)
maxInGroupName = num
}
})
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.groups.push({
output_type: VarType.any,
variables: [],
group_name: `Group${maxInGroupName + 1}`,
groupId: uuid4(),
})
})
setInputs(newInputs)
setInputs(addGroup(inputs))
deleteNodeInspectorVars(id)
}, [deleteNodeInspectorVars, id, inputs, setInputs])
// record the first old name value
const oldNameRecord = useRef<Record<string, string>>({})
const oldNameRef = useRef<Record<string, string>>({})
const {
run: renameInspectNameWithDebounce,
} = useDebounceFn(
(id: string, newName: string) => {
const oldName = oldNameRecord.current[id]
const oldName = oldNameRef.current[id]
renameInspectVarName(id, oldName, newName)
delete oldNameRecord.current[id]
delete oldNameRef.current[id]
},
{ wait: 500 },
)
@ -160,13 +111,10 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
const handleVarGroupNameChange = useCallback((groupId: string) => {
return (name: string) => {
const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId)
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.groups[index].group_name = name
})
handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[index].group_name, 'output'], [id, name, 'output'])
setInputs(newInputs)
if (!(id in oldNameRecord.current))
oldNameRecord.current[id] = inputs.advanced_settings.groups[index].group_name
setInputs(renameGroup(inputs, groupId, name))
if (!(id in oldNameRef.current))
oldNameRef.current[id] = inputs.advanced_settings.groups[index].group_name
renameInspectNameWithDebounce(id, name)
}
}, [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs])
@ -177,19 +125,11 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
})
hideRemoveVarConfirm()
if (removeType === 'group') {
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.groups.splice(removedGroupIndex, 1)
})
setInputs(newInputs)
setInputs(removeGroupByIndex(inputs, removedGroupIndex))
}
else {
// removeType === 'enableChanged' to enabled
const newInputs = produce(inputs, (draft) => {
draft.advanced_settings.group_enabled = false
draft.output_type = draft.advanced_settings.groups[0].output_type
draft.variables = draft.advanced_settings.groups[0].variables
})
setInputs(newInputs)
setInputs(toggleGroupEnabled({ inputs, enabled: false }))
}
}, [removedVars, hideRemoveVarConfirm, removeType, removeUsedVarInNodes, inputs, setInputs, removedGroupIndex])
@ -207,7 +147,7 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => {
hideRemoveVarConfirm,
onRemoveVarConfirm,
getAvailableVars,
filterVar,
filterVar: filterVarByType,
}
}