mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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]
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks'
|
||||
import { BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
import { parseCurl } from './curl-parser'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
@ -18,104 +18,6 @@ type Props = {
|
||||
handleCurlImport: (node: HttpNodeType) => void
|
||||
}
|
||||
|
||||
const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
|
||||
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
|
||||
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
|
||||
|
||||
const node: Partial<HttpNodeType> = {
|
||||
title: 'HTTP Request',
|
||||
desc: 'Imported from cURL',
|
||||
method: undefined,
|
||||
url: '',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: '' },
|
||||
}
|
||||
const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
|
||||
let hasData = false
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i].replace(/^['"]|['"]$/g, '')
|
||||
switch (arg) {
|
||||
case '-X':
|
||||
case '--request':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing HTTP method after -X or --request.' }
|
||||
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
|
||||
hasData = true
|
||||
break
|
||||
case '-H':
|
||||
case '--header':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing header value after -H or --header.' }
|
||||
node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
|
||||
break
|
||||
case '-d':
|
||||
case '--data':
|
||||
case '--data-raw':
|
||||
case '--data-binary': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
|
||||
const bodyPayload = [{
|
||||
type: BodyPayloadValueType.text,
|
||||
value: args[++i].replace(/^['"]|['"]$/g, ''),
|
||||
}]
|
||||
node.body = { type: BodyType.rawText, data: bodyPayload }
|
||||
break
|
||||
}
|
||||
case '-F':
|
||||
case '--form': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing form data after -F or --form.' }
|
||||
if (node.body?.type !== BodyType.formData)
|
||||
node.body = { type: BodyType.formData, data: '' }
|
||||
const formData = args[++i].replace(/^['"]|['"]$/g, '')
|
||||
const [key, ...valueParts] = formData.split('=')
|
||||
if (!key)
|
||||
return { node: null, error: 'Invalid form data format.' }
|
||||
let value = valueParts.join('=')
|
||||
|
||||
// To support command like `curl -F "file=@/path/to/file;type=application/zip"`
|
||||
// the `;type=application/zip` should translate to `Content-Type: application/zip`
|
||||
const typeRegex = /^(.+?);type=(.+)$/
|
||||
const typeMatch = typeRegex.exec(value)
|
||||
if (typeMatch) {
|
||||
const [, actualValue, mimeType] = typeMatch
|
||||
value = actualValue
|
||||
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
|
||||
}
|
||||
|
||||
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
|
||||
break
|
||||
}
|
||||
case '--json':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing JSON data after --json.' }
|
||||
node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
|
||||
break
|
||||
default:
|
||||
if (arg.startsWith('http') && !node.url)
|
||||
node.url = arg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final method
|
||||
node.method = node.method || (hasData ? Method.post : Method.get)
|
||||
|
||||
if (!node.url)
|
||||
return { node: null, error: 'Missing URL or url not start with http.' }
|
||||
|
||||
// Extract query params from URL
|
||||
const urlParts = node.url?.split('?') || []
|
||||
if (urlParts.length > 1) {
|
||||
node.url = urlParts[0]
|
||||
node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
|
||||
}
|
||||
|
||||
return { node: node as HttpNodeType, error: null }
|
||||
}
|
||||
|
||||
const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
|
||||
const [inputString, setInputString] = useState('')
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal file
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { HttpNodeType } from '../types'
|
||||
import { BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
|
||||
const METHOD_ARG_FLAGS = new Set(['-X', '--request'])
|
||||
const HEADER_ARG_FLAGS = new Set(['-H', '--header'])
|
||||
const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary'])
|
||||
const FORM_ARG_FLAGS = new Set(['-F', '--form'])
|
||||
|
||||
type ParseStepResult = {
|
||||
error: string | null
|
||||
nextIndex: number
|
||||
hasData?: boolean
|
||||
}
|
||||
|
||||
const stripWrappedQuotes = (value: string) => {
|
||||
return value.replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
const parseCurlArgs = (curlCommand: string) => {
|
||||
return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
|
||||
}
|
||||
|
||||
const buildDefaultNode = (): Partial<HttpNodeType> => ({
|
||||
title: 'HTTP Request',
|
||||
desc: 'Imported from cURL',
|
||||
method: undefined,
|
||||
url: '',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: '' },
|
||||
})
|
||||
|
||||
const extractUrlParams = (url: string) => {
|
||||
const urlParts = url.split('?')
|
||||
if (urlParts.length <= 1)
|
||||
return { url, params: '' }
|
||||
|
||||
return {
|
||||
url: urlParts[0],
|
||||
params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '),
|
||||
}
|
||||
}
|
||||
|
||||
const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => {
|
||||
if (index + 1 >= args.length)
|
||||
return { value: null, error }
|
||||
|
||||
return {
|
||||
value: stripWrappedQuotes(args[index + 1]),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index, hasData: false }
|
||||
|
||||
node.method = (nextArg.value.toLowerCase() as Method) || Method.get
|
||||
return { error: null, nextIndex: index + 1, hasData: true }
|
||||
}
|
||||
|
||||
const applyHeaderArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.headers += `${node.headers ? '\n' : ''}${nextArg.value}`
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.body = {
|
||||
type: BodyType.rawText,
|
||||
data: [{ type: BodyPayloadValueType.text, value: nextArg.value }],
|
||||
}
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
if (node.body?.type !== BodyType.formData)
|
||||
node.body = { type: BodyType.formData, data: '' }
|
||||
|
||||
const [key, ...valueParts] = nextArg.value.split('=')
|
||||
if (!key)
|
||||
return { error: 'Invalid form data format.', nextIndex: index }
|
||||
|
||||
let value = valueParts.join('=')
|
||||
const typeMatch = /^(.+?);type=(.+)$/.exec(value)
|
||||
if (typeMatch) {
|
||||
const [, actualValue, mimeType] = typeMatch
|
||||
value = actualValue
|
||||
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
|
||||
}
|
||||
|
||||
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyJsonArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing JSON data after --json.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.body = { type: BodyType.json, data: nextArg.value }
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const handleCurlArg = (
|
||||
arg: string,
|
||||
node: Partial<HttpNodeType>,
|
||||
args: string[],
|
||||
index: number,
|
||||
): ParseStepResult => {
|
||||
if (METHOD_ARG_FLAGS.has(arg))
|
||||
return applyMethodArg(node, args, index)
|
||||
|
||||
if (HEADER_ARG_FLAGS.has(arg))
|
||||
return applyHeaderArg(node, args, index)
|
||||
|
||||
if (DATA_ARG_FLAGS.has(arg))
|
||||
return applyDataArg(node, args, index)
|
||||
|
||||
if (FORM_ARG_FLAGS.has(arg))
|
||||
return applyFormArg(node, args, index)
|
||||
|
||||
if (arg === '--json')
|
||||
return applyJsonArg(node, args, index)
|
||||
|
||||
if (arg.startsWith('http') && !node.url)
|
||||
node.url = arg
|
||||
|
||||
return { error: null, nextIndex: index, hasData: false }
|
||||
}
|
||||
|
||||
export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
|
||||
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
|
||||
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
|
||||
|
||||
const node = buildDefaultNode()
|
||||
const args = parseCurlArgs(curlCommand)
|
||||
let hasData = false
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i)
|
||||
if (result.error)
|
||||
return { node: null, error: result.error }
|
||||
|
||||
hasData ||= Boolean(result.hasData)
|
||||
i = result.nextIndex
|
||||
}
|
||||
|
||||
node.method = node.method || (hasData ? Method.post : Method.get)
|
||||
|
||||
if (!node.url)
|
||||
return { node: null, error: 'Missing URL or url not start with http.' }
|
||||
|
||||
const parsedUrl = extractUrlParams(node.url)
|
||||
node.url = parsedUrl.url
|
||||
node.params = parsedUrl.params
|
||||
|
||||
return { node: node as HttpNodeType, error: null }
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
|
||||
|
||||
describe('variable-in-markdown', () => {
|
||||
describe('rehypeVariable', () => {
|
||||
it('should replace variable tokens with variable elements and preserve surrounding text', () => {
|
||||
const tree = {
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#node.field#}} world',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeVariable()(tree)
|
||||
|
||||
expect(tree.children).toEqual([
|
||||
{ type: 'text', value: 'Hello ' },
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: '{{#node.field#}}' },
|
||||
children: [],
|
||||
},
|
||||
{ type: 'text', value: ' world' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore note tokens while processing variable nodes', () => {
|
||||
const tree = {
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#$node.field#}} world',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeVariable()(tree)
|
||||
|
||||
expect(tree.children).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
value: 'Hello {{#$node.field#}} world',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rehypeNotes', () => {
|
||||
it('should replace note tokens with section nodes and update the parent tag name', () => {
|
||||
const tree = {
|
||||
tagName: 'p',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: 'See {{#$node.title#}} please',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rehypeNotes()(tree)
|
||||
|
||||
expect(tree.tagName).toBe('div')
|
||||
expect(tree.children).toEqual([
|
||||
{ type: 'text', value: 'See ' },
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'section',
|
||||
properties: { dataName: 'title' },
|
||||
children: [],
|
||||
},
|
||||
{ type: 'text', value: ' please' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should format variable paths for display', () => {
|
||||
render(<Variable path="{{#node.field#}}" />)
|
||||
|
||||
expect(screen.getByText('{{node/field}}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render note values and replace node ids with labels for variable defaults', () => {
|
||||
const { rerender } = render(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'output'],
|
||||
value: '',
|
||||
}}
|
||||
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('{{Start Node/output}}')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'constant',
|
||||
value: 'Plain value',
|
||||
selector: [],
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plain value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -4,121 +4,130 @@ import type { FormInputItemDefault } from '../types'
|
||||
const variableRegex = /\{\{#(.+?)#\}\}/g
|
||||
const noteRegex = /\{\{#\$(.+?)#\}\}/g
|
||||
|
||||
export function rehypeVariable() {
|
||||
return (tree: any) => {
|
||||
const iterate = (node: any, index: number, parent: any) => {
|
||||
const value = node.value
|
||||
type MarkdownNode = {
|
||||
type?: string
|
||||
value?: string
|
||||
tagName?: string
|
||||
properties?: Record<string, string>
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
type SplitMatchResult = {
|
||||
tagName: string
|
||||
properties: Record<string, string>
|
||||
}
|
||||
|
||||
const splitTextNode = (
|
||||
value: string,
|
||||
regex: RegExp,
|
||||
createMatchNode: (match: RegExpExecArray) => SplitMatchResult,
|
||||
) => {
|
||||
const parts: MarkdownNode[] = []
|
||||
let lastIndex = 0
|
||||
let match = regex.exec(value)
|
||||
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex)
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
|
||||
|
||||
const { tagName, properties } = createMatchNode(match)
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName,
|
||||
properties,
|
||||
children: [],
|
||||
})
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
match = regex.exec(value)
|
||||
}
|
||||
|
||||
if (!parts.length)
|
||||
return parts
|
||||
|
||||
if (lastIndex < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex) })
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const visitTextNodes = (
|
||||
node: MarkdownNode,
|
||||
transform: (value: string, parent: MarkdownNode) => MarkdownNode[] | null,
|
||||
) => {
|
||||
if (!node.children)
|
||||
return
|
||||
|
||||
let index = 0
|
||||
while (index < node.children.length) {
|
||||
const child = node.children[index]
|
||||
if (child.type === 'text' && typeof child.value === 'string') {
|
||||
const nextNodes = transform(child.value, node)
|
||||
if (nextNodes) {
|
||||
node.children.splice(index, 1, ...nextNodes)
|
||||
index += nextNodes.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
visitTextNodes(child, transform)
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
const replaceNodeIdsWithNames = (path: string, nodeName: (nodeId: string) => string) => {
|
||||
return path.replace(/#([^#.]+)([.#])/g, (_, nodeId: string, separator: string) => {
|
||||
return `#${nodeName(nodeId)}${separator}`
|
||||
})
|
||||
}
|
||||
|
||||
const formatVariablePath = (path: string) => {
|
||||
return path.replaceAll('.', '/')
|
||||
.replace('{{#', '{{')
|
||||
.replace('#}}', '}}')
|
||||
}
|
||||
|
||||
export function rehypeVariable() {
|
||||
return (tree: MarkdownNode) => {
|
||||
visitTextNodes(tree, (value) => {
|
||||
variableRegex.lastIndex = 0
|
||||
noteRegex.lastIndex = 0
|
||||
if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) {
|
||||
let m: RegExpExecArray | null
|
||||
let last = 0
|
||||
const parts: any[] = []
|
||||
variableRegex.lastIndex = 0
|
||||
m = variableRegex.exec(value)
|
||||
while (m !== null) {
|
||||
if (m.index > last)
|
||||
parts.push({ type: 'text', value: value.slice(last, m.index) })
|
||||
if (!variableRegex.test(value) || noteRegex.test(value))
|
||||
return null
|
||||
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: m[0].trim() },
|
||||
children: [],
|
||||
})
|
||||
|
||||
last = m.index + m[0].length
|
||||
m = variableRegex.exec(value)
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
if (last < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(last) })
|
||||
|
||||
parent.children.splice(index, 1, ...parts)
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < node.children.length) {
|
||||
iterate(node.children[i], i, node)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < tree.children.length) {
|
||||
iterate(tree.children[i], i, tree)
|
||||
i++
|
||||
}
|
||||
variableRegex.lastIndex = 0
|
||||
return splitTextNode(value, variableRegex, match => ({
|
||||
tagName: 'variable',
|
||||
properties: { dataPath: match[0].trim() },
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function rehypeNotes() {
|
||||
return (tree: any) => {
|
||||
const iterate = (node: any, index: number, parent: any) => {
|
||||
const value = node.value
|
||||
return (tree: MarkdownNode) => {
|
||||
visitTextNodes(tree, (value, parent) => {
|
||||
noteRegex.lastIndex = 0
|
||||
if (!noteRegex.test(value))
|
||||
return null
|
||||
|
||||
noteRegex.lastIndex = 0
|
||||
if (node.type === 'text' && noteRegex.test(value)) {
|
||||
let m: RegExpExecArray | null
|
||||
let last = 0
|
||||
const parts: any[] = []
|
||||
noteRegex.lastIndex = 0
|
||||
m = noteRegex.exec(value)
|
||||
while (m !== null) {
|
||||
if (m.index > last)
|
||||
parts.push({ type: 'text', value: value.slice(last, m.index) })
|
||||
|
||||
const name = m[0].split('.').slice(-1)[0].replace('#}}', '')
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'section',
|
||||
properties: { dataName: name },
|
||||
children: [],
|
||||
})
|
||||
|
||||
last = m.index + m[0].length
|
||||
m = noteRegex.exec(value)
|
||||
parent.tagName = 'div'
|
||||
return splitTextNode(value, noteRegex, (match) => {
|
||||
const name = match[0].split('.').slice(-1)[0].replace('#}}', '')
|
||||
return {
|
||||
tagName: 'section',
|
||||
properties: { dataName: name },
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
if (last < value.length)
|
||||
parts.push({ type: 'text', value: value.slice(last) })
|
||||
|
||||
parent.children.splice(index, 1, ...parts)
|
||||
parent.tagName = 'div' // h2 can not in p. In note content include the h2
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < node.children.length) {
|
||||
iterate(node.children[i], i, node)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = 0
|
||||
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
|
||||
while (i < tree.children.length) {
|
||||
iterate(tree.children[i], i, tree)
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Variable: React.FC<{ path: string }> = ({ path }) => {
|
||||
return (
|
||||
<span className="text-text-accent">
|
||||
{
|
||||
path.replaceAll('.', '/')
|
||||
.replace('{{#', '{{')
|
||||
.replace('#}}', '}}')
|
||||
}
|
||||
{formatVariablePath(path)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -126,12 +135,7 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
|
||||
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
|
||||
const isVariable = defaultInput.type === 'variable'
|
||||
const path = `{{#${defaultInput.selector.join('.')}#}}`
|
||||
let newPath = path
|
||||
if (path) {
|
||||
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
|
||||
return `#${nodeName(nodeId)}${sep}`
|
||||
})
|
||||
}
|
||||
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
|
||||
return (
|
||||
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
|
||||
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
|
||||
|
||||
@ -0,0 +1,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())
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}))
|
||||
})
|
||||
})
|
||||
237
web/app/components/workflow/nodes/if-else/use-config.helpers.ts
Normal file
237
web/app/components/workflow/nodes/if-else/use-config.helpers.ts
Normal 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
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -17,6 +17,8 @@ import { ComparisonOperator } from '../../if-else/types'
|
||||
import { comparisonOperatorNotRequireValue, getOperators } from '../../if-else/utils'
|
||||
import SubVariablePicker from './sub-variable-picker'
|
||||
|
||||
type VariableInputProps = React.ComponentProps<typeof Input>
|
||||
|
||||
const optionNameI18NPrefix = 'nodes.ifElse.optionName'
|
||||
|
||||
const VAR_INPUT_SUPPORTED_KEYS: Record<string, VarType> = {
|
||||
@ -37,6 +39,147 @@ type Props = {
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
const getExpectedVarType = (condition: Condition, varType: VarType) => {
|
||||
return condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
|
||||
}
|
||||
|
||||
const getSelectOptions = (
|
||||
condition: Condition,
|
||||
isSelect: boolean,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) => {
|
||||
if (!isSelect)
|
||||
return []
|
||||
|
||||
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
|
||||
return FILE_TYPE_OPTIONS.map(item => ({
|
||||
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
|
||||
value: item.value,
|
||||
}))
|
||||
}
|
||||
|
||||
if (condition.key === 'transfer_method') {
|
||||
return TRANSFER_METHOD.map(item => ({
|
||||
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
|
||||
value: item.value,
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const getFallbackInputType = ({
|
||||
hasSubVariable,
|
||||
condition,
|
||||
varType,
|
||||
}: {
|
||||
hasSubVariable: boolean
|
||||
condition: Condition
|
||||
varType: VarType
|
||||
}) => {
|
||||
return ((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number))
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
|
||||
const ValueInput = ({
|
||||
comparisonOperator,
|
||||
isSelect,
|
||||
isArrayValue,
|
||||
isBoolean,
|
||||
supportVariableInput,
|
||||
selectOptions,
|
||||
condition,
|
||||
readOnly,
|
||||
availableVars,
|
||||
availableNodesWithParent,
|
||||
onFocusChange,
|
||||
onChange,
|
||||
hasSubVariable,
|
||||
varType,
|
||||
t,
|
||||
}: {
|
||||
comparisonOperator: ComparisonOperator
|
||||
isSelect: boolean
|
||||
isArrayValue: boolean
|
||||
isBoolean: boolean
|
||||
supportVariableInput: boolean
|
||||
selectOptions: Array<{ name: string, value: string }>
|
||||
condition: Condition
|
||||
readOnly: boolean
|
||||
availableVars: VariableInputProps['nodesOutputVars']
|
||||
availableNodesWithParent: VariableInputProps['availableNodes']
|
||||
onFocusChange: (value: boolean) => void
|
||||
onChange: (value: unknown) => void
|
||||
hasSubVariable: boolean
|
||||
varType: VarType
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) => {
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
const handleFocusChange = (value: boolean) => {
|
||||
setIsFocus(value)
|
||||
onFocusChange(value)
|
||||
}
|
||||
|
||||
if (comparisonOperatorNotRequireValue(comparisonOperator))
|
||||
return null
|
||||
|
||||
if (isSelect) {
|
||||
return (
|
||||
<Select
|
||||
items={selectOptions}
|
||||
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
|
||||
onSelect={item => onChange(item.value)}
|
||||
className="!text-[13px]"
|
||||
wrapperClassName="grow h-8"
|
||||
placeholder="Select value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isBoolean) {
|
||||
return (
|
||||
<BoolValue
|
||||
value={condition.value as boolean}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (supportVariableInput) {
|
||||
return (
|
||||
<Input
|
||||
instanceId="filter-condition-input"
|
||||
className={cn(
|
||||
isFocus
|
||||
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
|
||||
: 'border-components-input-border-hover bg-components-input-bg-normal',
|
||||
'w-0 grow rounded-lg border px-3 py-[6px]',
|
||||
)}
|
||||
value={getConditionValueAsString(condition)}
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
onFocusChange={handleFocusChange}
|
||||
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
|
||||
placeholderClassName="!leading-[21px]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={getFallbackInputType({ hasSubVariable, condition, varType })}
|
||||
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
|
||||
value={getConditionValueAsString(condition)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterCondition: FC<Props> = ({
|
||||
condition = { key: '', comparison_operator: ComparisonOperator.equal, value: '' },
|
||||
varType,
|
||||
@ -46,9 +189,8 @@ const FilterCondition: FC<Props> = ({
|
||||
nodeId,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
const expectedVarType = condition.key ? VAR_INPUT_SUPPORTED_KEYS[condition.key] : varType
|
||||
const expectedVarType = getExpectedVarType(condition, varType)
|
||||
const supportVariableInput = !!expectedVarType
|
||||
|
||||
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
|
||||
@ -62,24 +204,7 @@ const FilterCondition: FC<Props> = ({
|
||||
const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type'
|
||||
const isBoolean = varType === VarType.boolean
|
||||
|
||||
const selectOptions = useMemo(() => {
|
||||
if (isSelect) {
|
||||
if (condition.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
|
||||
return FILE_TYPE_OPTIONS.map(item => ({
|
||||
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
|
||||
value: item.value,
|
||||
}))
|
||||
}
|
||||
if (condition.key === 'transfer_method') {
|
||||
return TRANSFER_METHOD.map(item => ({
|
||||
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`, { ns: 'workflow' }),
|
||||
value: item.value,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}, [condition.comparison_operator, condition.key, isSelect, t])
|
||||
const selectOptions = useMemo(() => getSelectOptions(condition, isSelect, t), [condition, isSelect, t])
|
||||
|
||||
const handleChange = useCallback((key: string) => {
|
||||
return (value: any) => {
|
||||
@ -100,67 +225,6 @@ const FilterCondition: FC<Props> = ({
|
||||
})
|
||||
}, [onChange, expectedVarType])
|
||||
|
||||
// Extract input rendering logic to avoid nested ternary
|
||||
let inputElement: React.ReactNode = null
|
||||
if (!comparisonOperatorNotRequireValue(condition.comparison_operator)) {
|
||||
if (isSelect) {
|
||||
inputElement = (
|
||||
<Select
|
||||
items={selectOptions}
|
||||
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
|
||||
onSelect={item => handleChange('value')(item.value)}
|
||||
className="!text-[13px]"
|
||||
wrapperClassName="grow h-8"
|
||||
placeholder="Select value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (isBoolean) {
|
||||
inputElement = (
|
||||
<BoolValue
|
||||
value={condition.value as boolean}
|
||||
onChange={handleChange('value')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (supportVariableInput) {
|
||||
inputElement = (
|
||||
<Input
|
||||
instanceId="filter-condition-input"
|
||||
className={cn(
|
||||
isFocus
|
||||
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
|
||||
: 'border-components-input-border-hover bg-components-input-bg-normal',
|
||||
'w-0 grow rounded-lg border px-3 py-[6px]',
|
||||
)}
|
||||
value={
|
||||
getConditionValueAsString(condition)
|
||||
}
|
||||
onChange={handleChange('value')}
|
||||
readOnly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
onFocusChange={setIsFocus}
|
||||
placeholder={!readOnly ? t('nodes.http.insertVarPlaceholder', { ns: 'workflow' })! : ''}
|
||||
placeholderClassName="!leading-[21px]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
inputElement = (
|
||||
<input
|
||||
type={((hasSubVariable && condition.key === 'size') || (!hasSubVariable && varType === VarType.number)) ? 'number' : 'text'}
|
||||
className="grow rounded-lg border border-components-input-border-hover bg-components-input-bg-normal px-3 py-[6px]"
|
||||
value={
|
||||
getConditionValueAsString(condition)
|
||||
}
|
||||
onChange={e => handleChange('value')(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasSubVariable && (
|
||||
@ -179,7 +243,23 @@ const FilterCondition: FC<Props> = ({
|
||||
file={hasSubVariable ? { key: condition.key } : undefined}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{inputElement}
|
||||
<ValueInput
|
||||
comparisonOperator={condition.comparison_operator}
|
||||
isSelect={isSelect}
|
||||
isArrayValue={isArrayValue}
|
||||
isBoolean={isBoolean}
|
||||
supportVariableInput={supportVariableInput}
|
||||
selectOptions={selectOptions}
|
||||
condition={condition}
|
||||
readOnly={readOnly}
|
||||
availableVars={availableVars}
|
||||
availableNodesWithParent={availableNodesWithParent}
|
||||
onFocusChange={(_value) => {}}
|
||||
onChange={handleChange('value')}
|
||||
hasSubVariable={hasSubVariable}
|
||||
varType={varType}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,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
|
||||
})
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -184,6 +184,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
171
web/app/components/workflow/nodes/loop/use-config.helpers.ts
Normal file
171
web/app/components/workflow/nodes/loop/use-config.helpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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}`,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -75,6 +75,8 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@ -64,6 +64,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -57,6 +57,126 @@ type DisplayRow = {
|
||||
isVirtual: boolean // whether this row is the extra empty row for adding new items
|
||||
}
|
||||
|
||||
const isEmptyRow = (row: GenericTableRow) => {
|
||||
return Object.values(row).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
}
|
||||
|
||||
const getDisplayRows = (
|
||||
data: GenericTableRow[],
|
||||
emptyRowData: GenericTableRow,
|
||||
readonly: boolean,
|
||||
): DisplayRow[] => {
|
||||
if (readonly)
|
||||
return data.map((row, index) => ({ row, dataIndex: index, isVirtual: false }))
|
||||
|
||||
if (!data.length)
|
||||
return [{ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }]
|
||||
|
||||
const rows = data.reduce<DisplayRow[]>((acc, row, index) => {
|
||||
if (isEmptyRow(row) && index < data.length - 1)
|
||||
return acc
|
||||
|
||||
acc.push({ row, dataIndex: index, isVirtual: false })
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const lastRow = data.at(-1)
|
||||
if (lastRow && !isEmptyRow(lastRow))
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const getPrimaryKey = (columns: ColumnConfig[]) => {
|
||||
return columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
}
|
||||
|
||||
const renderInputCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSelectCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSwitchCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
dataIndex: number | null,
|
||||
readonly: boolean,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCustomCell = (
|
||||
column: ColumnConfig,
|
||||
value: unknown,
|
||||
row: GenericTableRow,
|
||||
dataIndex: number | null,
|
||||
handleChange: (value: unknown) => void,
|
||||
) => {
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
}
|
||||
|
||||
const GenericTable: FC<GenericTableProps> = ({
|
||||
title,
|
||||
columns,
|
||||
@ -68,42 +188,8 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className,
|
||||
showHeader = false,
|
||||
}) => {
|
||||
// Build the rows to display while keeping a stable mapping to original data
|
||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
||||
// Helper to check empty
|
||||
const isEmptyRow = (r: GenericTableRow) =>
|
||||
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
|
||||
if (readonly)
|
||||
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
|
||||
|
||||
const hasData = data.length > 0
|
||||
const rows: DisplayRow[] = []
|
||||
|
||||
if (!hasData) {
|
||||
// Initialize with exactly one empty row when there is no data
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
// Add configured rows, hide intermediate empty ones, keep mapping
|
||||
data.forEach((r, i) => {
|
||||
const isEmpty = isEmptyRow(r)
|
||||
// Skip empty rows except the very last configured row
|
||||
if (isEmpty && i < data.length - 1)
|
||||
return
|
||||
rows.push({ row: r, dataIndex: i, isVirtual: false })
|
||||
})
|
||||
|
||||
// If the last configured row has content, append a trailing empty row
|
||||
const lastRow = data.at(-1)
|
||||
if (!lastRow)
|
||||
return rows
|
||||
const lastHasContent = !isEmptyRow(lastRow)
|
||||
if (lastHasContent)
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
return getDisplayRows(data, emptyRowData, readonly)
|
||||
}, [data, emptyRowData, readonly])
|
||||
|
||||
const removeRow = useCallback((dataIndex: number) => {
|
||||
@ -134,9 +220,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
}, [data, emptyRowData, onChange, readonly])
|
||||
|
||||
// Determine the primary identifier column just once
|
||||
const primaryKey = useMemo(() => (
|
||||
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
), [columns])
|
||||
const primaryKey = useMemo(() => getPrimaryKey(columns), [columns])
|
||||
|
||||
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
|
||||
const value = row[column.key]
|
||||
@ -144,67 +228,16 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
|
||||
switch (column.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
// Format variable names (replace spaces with underscores)
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
// Ghost/inline style: looks like plain text until focus/hover
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'text-text-secondary system-sm-regular placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
return renderInputCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
// wrapper provides compact height, trigger is transparent like text
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
return renderSelectCell(column, value, readonly, handleChange)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return renderSwitchCell(column, value, dataIndex, readonly, handleChange)
|
||||
|
||||
case 'custom':
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
return renderCustomCell(column, value, row, dataIndex, handleChange)
|
||||
|
||||
default:
|
||||
return null
|
||||
@ -270,6 +303,7 @@ const GenericTable: FC<GenericTableProps> = ({
|
||||
className="p-1"
|
||||
aria-label="Delete row"
|
||||
>
|
||||
{/* eslint-disable-next-line hyoban/prefer-tailwind-icons */}
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,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
|
||||
})
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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 }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user