feat(workflow): implement variable modal state management and helpers

- Introduced a new custom hook  to manage the state of the variable modal, encapsulating logic for handling variable types, values, and validation.
- Created helper functions for formatting and validating variable data, improving modularity and reusability.
- Added new components for rendering variable modal sections, enhancing the user interface and user experience.
- Implemented comprehensive unit tests for the new hook and helpers to ensure functionality and correctness.
This commit is contained in:
CodingOnStar
2026-03-24 18:22:17 +08:00
parent 20f901223b
commit 3c58c68b8d
11 changed files with 1462 additions and 379 deletions

View File

@@ -3,12 +3,49 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { useNodes } from 'reactflow'
import SelectionContextmenu from '../selection-contextmenu'
import { AlignType } from '../selection-contextmenu.helpers'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { createEdge, createNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
let latestNodes: Node[] = []
let latestHistoryEvent: string | undefined
const mockGetAlignBounds = vi.fn()
const mockAlignNodePosition = vi.fn()
const mockGetAlignableNodes = vi.fn()
const mockGetNodesReadOnly = vi.fn()
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
}
})
vi.mock('../selection-contextmenu.helpers', async () => {
const actual = await vi.importActual<typeof import('../selection-contextmenu.helpers')>('../selection-contextmenu.helpers')
return {
...actual,
alignNodePosition: (...args: Parameters<typeof actual.alignNodePosition>) => {
if (mockAlignNodePosition.getMockImplementation())
return mockAlignNodePosition(...args)
return actual.alignNodePosition(...args)
},
getAlignableNodes: (...args: Parameters<typeof actual.getAlignableNodes>) => {
if (mockGetAlignableNodes.getMockImplementation())
return mockGetAlignableNodes(...args)
return actual.getAlignableNodes(...args)
},
getAlignBounds: (...args: Parameters<typeof actual.getAlignBounds>) => {
if (mockGetAlignBounds.getMockImplementation())
return mockGetAlignBounds(...args)
return actual.getAlignBounds(...args)
},
}
})
const RuntimeProbe = () => {
latestNodes = useNodes() as Node[]
@@ -60,6 +97,11 @@ describe('SelectionContextmenu', () => {
vi.clearAllMocks()
latestNodes = []
latestHistoryEvent = undefined
mockGetAlignBounds.mockReset()
mockAlignNodePosition.mockReset()
mockGetAlignableNodes.mockReset()
mockGetNodesReadOnly.mockReset()
mockGetNodesReadOnly.mockReturnValue(false)
})
it('should not render when selectionMenu is absent', () => {
@@ -197,4 +239,84 @@ describe('SelectionContextmenu', () => {
expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40)
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210)
})
it('should cancel when align bounds cannot be resolved', () => {
mockGetAlignBounds.mockReturnValue(null)
const nodes = [
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
})
it('should cancel without aligning when nodes are read only', () => {
mockGetNodesReadOnly.mockReturnValue(true)
const nodes = [
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0)
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80)
})
it('should cancel when alignable nodes shrink to one item', () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
mockGetAlignableNodes.mockImplementation((allNodes: Node[]) => [allNodes[0]])
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0)
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80)
})
it('should skip align updates when a selected node is not found in the draft', () => {
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }),
]
mockGetAlignableNodes.mockImplementation((allNodes: Node[]) => [
allNodes[0],
{ ...allNodes[1], id: 'missing-node' },
])
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId(`selection-contextmenu-item-${AlignType.Right}`))
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(160)
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(140)
})
})

View File

@@ -1,5 +1,6 @@
import type { TFunction } from 'i18next'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import { NodeBody, NodeDescription, NodeHeaderMeta } from '../node-sections'
@@ -38,4 +39,97 @@ describe('node sections', () => {
rerender(<NodeDescription data={{ type: BlockEnum.Tool, desc: 'node description' } as never} />)
expect(screen.getByText('node description')).toBeInTheDocument()
})
it('should render iteration parallel metadata and running progress', async () => {
const t = ((key: string) => key) as unknown as TFunction
const user = userEvent.setup()
render(
<NodeHeaderMeta
data={{
type: BlockEnum.Iteration,
is_parallel: true,
_iterationLength: 3,
_iterationIndex: 5,
_runningStatus: NodeRunningStatus.Running,
} as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument()
await user.hover(screen.getByText('nodes.iteration.parallelModeUpper'))
expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument()
expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument()
expect(screen.getByText('3/3')).toBeInTheDocument()
})
it('should render failed, exception, success and paused status icons', () => {
const t = ((key: string) => key) as unknown as TFunction
const { rerender } = render(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Failed } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Exception } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Succeeded } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Paused } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
})
it('should render success icon when inspect vars exist without running status and hide description for loop nodes', () => {
const t = ((key: string) => key) as unknown as TFunction
const { rerender } = render(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool } as never}
hasVarValue
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument()
rerender(<NodeDescription data={{ type: BlockEnum.Loop, desc: 'hidden' } as never} />)
expect(screen.queryByText('hidden')).not.toBeInTheDocument()
})
})

View File

@@ -7,6 +7,9 @@ import BaseNode from '../node'
const mockHasNodeInspectVars = vi.fn()
const mockUseNodePluginInstallation = vi.fn()
const mockHandleNodeIterationChildSizeChange = vi.fn()
const mockHandleNodeLoopChildSizeChange = vi.fn()
const mockUseNodeResizeObserver = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
@@ -25,16 +28,24 @@ vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
vi.mock('@/app/components/workflow/nodes/iteration/use-interactions', () => ({
useNodeIterationInteractions: () => ({
handleNodeIterationChildSizeChange: vi.fn(),
handleNodeIterationChildSizeChange: mockHandleNodeIterationChildSizeChange,
}),
}))
vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopChildSizeChange: vi.fn(),
handleNodeLoopChildSizeChange: mockHandleNodeLoopChildSizeChange,
}),
}))
vi.mock('../use-node-resize-observer', () => ({
default: (options: { enabled: boolean, onResize: () => void }) => {
mockUseNodeResizeObserver(options)
if (options.enabled)
options.onResize()
},
}))
vi.mock('../components/add-variable-popup-with-position', () => ({
default: () => <div data-testid="add-var-popup" />,
}))
@@ -86,6 +97,7 @@ describe('BaseNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodeInspectVars.mockReturnValue(false)
mockUseNodeResizeObserver.mockReset()
mockUseNodePluginInstallation.mockReturnValue({
shouldDim: false,
isChecking: false,
@@ -183,5 +195,24 @@ describe('BaseNode', () => {
expect(screen.getByTestId('node-resizer')).toBeInTheDocument()
expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument()
expect(mockHandleNodeIterationChildSizeChange).toHaveBeenCalledWith('node-1')
})
it('should trigger loop resize updates when the selected node is inside a loop', () => {
renderWorkflowComponent(
<BaseNode
id="node-2"
data={toNodeData(createData({
type: BlockEnum.Loop,
selected: true,
isInLoop: true,
}))}
>
<div>Loop body</div>
</BaseNode>,
)
expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2')
expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,195 @@
import type { ChangeEvent } from 'react'
import { act, renderHook } from '@testing-library/react'
import { ChatVarType } from '../../type'
import { useVariableModalState } from '../use-variable-modal-state'
vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
const createOptions = (overrides: Partial<Parameters<typeof useVariableModalState>[0]> = {}) => ({
chatVar: undefined,
conversationVariables: [],
notify: vi.fn(),
onClose: vi.fn(),
onSave: vi.fn(),
t: (key: string) => key,
...overrides,
})
describe('useVariableModalState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build initial state from an existing array object variable', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-1',
name: 'payload',
description: 'desc',
value_type: ChatVarType.ArrayObject,
value: [{ enabled: true }],
},
})))
expect(result.current.name).toBe('payload')
expect(result.current.description).toBe('desc')
expect(result.current.type).toBe(ChatVarType.ArrayObject)
expect(result.current.editInJSON).toBe(true)
expect(result.current.editorContent).toBe(JSON.stringify([{ enabled: true }]))
})
it('should update state when changing types and editing scalar values', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.Object)
})
expect(result.current.type).toBe(ChatVarType.Object)
expect(result.current.objectValue).toHaveLength(1)
act(() => {
result.current.handleTypeChange(ChatVarType.Number)
result.current.handleStringOrNumberChange([12])
})
expect(result.current.value).toBe(12)
act(() => {
result.current.setDescription('note')
result.current.setValue(true)
})
expect(result.current.description).toBe('note')
expect(result.current.value).toBe(true)
})
it('should toggle object values between form and json modes', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-2',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: { timeout: 30 },
},
})))
act(() => {
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
act(() => {
result.current.handleEditorValueChange('{"timeout":45}')
result.current.handleEditorChange(false)
})
expect(result.current.editInJSON).toBe(false)
expect(result.current.objectValue).toEqual([
{ key: 'timeout', type: ChatVarType.Number, value: 45 },
])
})
it('should reset object form values when leaving empty json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-3',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: {},
},
})))
act(() => {
result.current.handleEditorChange(true)
result.current.handleEditorValueChange('')
result.current.handleEditorChange(false)
})
expect(result.current.objectValue).toHaveLength(1)
expect(result.current.value).toBeUndefined()
})
it('should handle array editor toggles and invalid json safely', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayString)
result.current.setValue(['a', '', 'b'])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleEditorValueChange('[invalid')
})
expect(result.current.editorContent).toBe('[invalid')
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleEditorChange(false)
})
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayBoolean)
result.current.setValue([true, false])
result.current.handleEditorChange(true)
})
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
})
it('should notify and stop saving when object keys are invalid', () => {
const notify = vi.fn()
const onSave = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useVariableModalState(createOptions({
notify,
onClose,
onSave,
})))
act(() => {
result.current.handleVarNameChange({ target: { value: 'config' } } as ChangeEvent<HTMLInputElement>)
result.current.handleTypeChange(ChatVarType.Object)
result.current.setObjectValue([{ key: '', type: ChatVarType.String, value: 'secret' }])
})
act(() => {
result.current.handleSave()
})
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'object key can not be empty' })
expect(onSave).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('should save a new variable and close when state is valid', () => {
const onSave = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useVariableModalState(createOptions({
onClose,
onSave,
})))
act(() => {
result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>)
result.current.handleStringOrNumberChange(['hello'])
})
act(() => {
result.current.handleSave()
})
expect(onSave).toHaveBeenCalledWith({
description: '',
id: 'generated-id',
name: 'greeting',
value: 'hello',
value_type: ChatVarType.String,
})
expect(onClose).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,123 @@
import { ChatVarType } from '../../type'
import {
buildObjectValueItems,
formatChatVariableValue,
formatObjectValueFromList,
getEditorMinHeight,
getEditorToggleLabelKey,
getPlaceholderByType,
getTypeChangeState,
parseEditorContent,
validateVariableName,
} from '../variable-modal.helpers'
describe('variable-modal helpers', () => {
it('should build object items from a conversation variable value', () => {
expect(buildObjectValueItems()).toHaveLength(1)
expect(buildObjectValueItems({
id: 'var-1',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: { apiKey: 'secret', timeout: 30 },
})).toEqual([
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
])
})
it('should format object and array values for saving', () => {
expect(formatObjectValueFromList([
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
{ key: '', type: ChatVarType.Number, value: 1 },
])).toEqual({ apiKey: 'secret' })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
type: ChatVarType.Object,
value: undefined,
})).toEqual({ enabled: 'true' })
expect(formatChatVariableValue({
editInJSON: true,
objectValue: [],
type: ChatVarType.Object,
value: { count: 1 },
})).toEqual({ count: 1 })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayString,
value: ['a', '', 'b'],
})).toEqual(['a', 'b'])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.Number,
value: undefined,
})).toBe(0)
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.Boolean,
value: undefined,
})).toBe(true)
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayBoolean,
value: undefined,
})).toEqual([])
})
it('should derive placeholders, editor defaults, and editor toggle labels', () => {
expect(getEditorMinHeight(ChatVarType.ArrayObject)).toBe('240px')
expect(getEditorMinHeight(ChatVarType.Object)).toBe('120px')
expect(getPlaceholderByType(ChatVarType.ArrayBoolean)).toBeTruthy()
expect(getTypeChangeState(ChatVarType.Boolean).value).toBe(false)
expect(getTypeChangeState(ChatVarType.ArrayBoolean).value).toEqual([false])
expect(getTypeChangeState(ChatVarType.Object).objectValue).toHaveLength(1)
expect(getTypeChangeState(ChatVarType.ArrayObject).editInJSON).toBe(true)
expect(getEditorToggleLabelKey(ChatVarType.Object, true)).toBe('chatVariable.modal.editInForm')
expect(getEditorToggleLabelKey(ChatVarType.ArrayString, false)).toBe('chatVariable.modal.editInJSON')
})
it('should parse boolean arrays from JSON editor content', () => {
expect(parseEditorContent({
content: '["True","false",true,false,"invalid"]',
type: ChatVarType.ArrayBoolean,
})).toEqual([true, false, true, false])
expect(parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.Object,
})).toEqual({ enabled: true })
})
it('should validate variable names and notify when invalid', () => {
const notify = vi.fn()
const t = (key: string) => key
expect(validateVariableName({
name: 'valid_name',
notify,
t,
})).toBe(true)
expect(validateVariableName({
name: '1invalid',
notify,
t,
})).toBe(false)
expect(notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})

View File

@@ -0,0 +1,193 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast/context'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ChatVarType } from '../../type'
import VariableModal from '../variable-modal'
vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
const renderVariableModal = (props?: Partial<React.ComponentProps<typeof VariableModal>>) => {
const onClose = vi.fn()
const onSave = vi.fn()
const notify = vi.fn()
const result = renderWorkflowComponent(
React.createElement(
ToastContext.Provider,
{
value: { notify, close: vi.fn() },
children: (
<VariableModal
onClose={onClose}
onSave={onSave}
{...props}
/>
),
},
),
)
return { ...result, notify, onClose, onSave }
}
describe('variable-modal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create a new string variable and close after saving', async () => {
const user = userEvent.setup()
const { onClose, onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'greeting')
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello')
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.descriptionPlaceholder'), 'demo variable')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'greeting',
value_type: ChatVarType.String,
value: 'hello',
description: 'demo variable',
})
expect(onClose).toHaveBeenCalled()
})
it('should reject duplicate variable names from the workflow store', async () => {
const user = userEvent.setup()
const { notify, onSave, store } = renderVariableModal()
store.setState({
conversationVariables: [{
id: 'var-1',
name: 'existing_name',
description: '',
value_type: ChatVarType.String,
value: '',
}],
})
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
await user.click(screen.getByText('common.operation.save'))
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'name is existed' })
expect(onSave).not.toHaveBeenCalled()
})
it('should load an existing object variable and save object values edited in form mode', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal({
chatVar: {
id: 'var-2',
name: 'config',
description: 'settings',
value_type: ChatVarType.Object,
value: { apiKey: 'secret', timeout: 30 },
},
})
expect(screen.getByDisplayValue('config')).toBeInTheDocument()
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
await user.clear(screen.getByDisplayValue('secret'))
await user.type(screen.getByDisplayValue('30'), '5')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'var-2',
name: 'config',
value_type: ChatVarType.Object,
value: {
apiKey: null,
timeout: 305,
},
description: 'settings',
})
})
it('should switch types and use default values for boolean arrays', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'flags')
await user.click(screen.getByText('string'))
await user.click(screen.getByText('array[boolean]'))
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'flags',
value_type: ChatVarType.ArrayBoolean,
value: [false],
description: '',
})
})
it('should toggle object editing modes without changing behavior', async () => {
const user = userEvent.setup()
renderVariableModal({
chatVar: {
id: 'var-3',
name: 'payload',
description: '',
value_type: ChatVarType.Object,
value: { enabled: 1 },
},
})
await user.click(screen.getByText('workflow.chatVariable.modal.editInJSON'))
await waitFor(() => {
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
await user.click(screen.getByText('workflow.chatVariable.modal.editInForm'))
expect(screen.getByDisplayValue('enabled')).toBeInTheDocument()
})
it('should validate variable names on blur and preserve underscore replacement', () => {
const { notify } = renderVariableModal()
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')
fireEvent.change(input, { target: { value: 'bad name' } })
fireEvent.blur(input)
expect((input as HTMLInputElement).value).toBe('bad_name')
expect(notify).not.toHaveBeenCalled()
})
it('should stop invalid variable names before they are stored in local state', async () => {
const { notify, onSave } = renderVariableModal()
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder') as HTMLInputElement
fireEvent.change(input, { target: { value: '1bad' } })
await userEvent.click(screen.getByText('common.operation.save'))
expect(input.value).toBe('')
expect(notify).toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
})
it('should edit number variables through the value input', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'timeout')
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), '3')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'timeout',
value_type: ChatVarType.Number,
value: 3,
description: '',
})
})
})

View File

@@ -0,0 +1,228 @@
import type { ObjectValueItem, ToastPayload } from './variable-modal.helpers'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { useMemo, useState } from 'react'
import { v4 as uuid4 } from 'uuid'
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import {
buildObjectValueItems,
formatChatVariableValue,
formatObjectValueFromList,
getEditorMinHeight,
getPlaceholderByType,
getTypeChangeState,
parseEditorContent,
validateVariableName,
} from './variable-modal.helpers'
type UseVariableModalStateOptions = {
chatVar?: ConversationVariable
conversationVariables: ConversationVariable[]
notify: (props: ToastPayload) => void
onClose: () => void
onSave: (chatVar: ConversationVariable) => void
t: (key: string, options?: Record<string, unknown>) => string
}
type VariableModalState = {
description: string
editInJSON: boolean
editorContent?: string
name: string
objectValue: ObjectValueItem[]
type: ChatVarType
value: unknown
}
const buildObjectValueListFromRecord = (record: Record<string, string | number>) => {
return Object.keys(record).map(key => ({
key,
type: typeof record[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: record[key],
}))
}
const buildInitialState = (chatVar?: ConversationVariable): VariableModalState => {
if (!chatVar) {
return {
description: '',
editInJSON: false,
editorContent: undefined,
name: '',
objectValue: [DEFAULT_OBJECT_VALUE],
type: ChatVarType.String,
value: undefined,
}
}
return {
description: chatVar.description,
editInJSON: chatVar.value_type === ChatVarType.ArrayObject,
editorContent: chatVar.value_type === ChatVarType.ArrayObject ? JSON.stringify(chatVar.value) : undefined,
name: chatVar.name,
objectValue: buildObjectValueItems(chatVar),
type: chatVar.value_type,
value: chatVar.value,
}
}
export const useVariableModalState = ({
chatVar,
conversationVariables,
notify,
onClose,
onSave,
t,
}: UseVariableModalStateOptions) => {
const [state, setState] = useState<VariableModalState>(() => buildInitialState(chatVar))
const editorMinHeight = useMemo(() => getEditorMinHeight(state.type), [state.type])
const placeholder = useMemo(() => getPlaceholderByType(state.type), [state.type])
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState(prev => ({ ...prev, name: e.target.value || '' }))
}
const handleTypeChange = (nextType: ChatVarType) => {
const nextState = getTypeChangeState(nextType)
setState(prev => ({
...prev,
editInJSON: nextState.editInJSON,
editorContent: nextState.editorContent,
objectValue: nextState.objectValue ?? prev.objectValue,
type: nextType,
value: nextState.value,
}))
}
const handleStringOrNumberChange = (nextValue: Array<string | number | undefined>) => {
setState(prev => ({ ...prev, value: nextValue[0] }))
}
const handleEditorChange = (nextEditInJSON: boolean) => {
setState((prev) => {
const nextState: VariableModalState = {
...prev,
editInJSON: nextEditInJSON,
}
if (prev.type === ChatVarType.Object) {
if (nextEditInJSON) {
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
nextState.value = nextValue
nextState.editorContent = JSON.stringify(nextValue)
return nextState
}
if (!prev.editorContent) {
nextState.value = undefined
nextState.objectValue = [DEFAULT_OBJECT_VALUE]
return nextState
}
try {
const nextValue = JSON.parse(prev.editorContent) as Record<string, string | number>
nextState.value = nextValue
nextState.objectValue = buildObjectValueListFromRecord(nextValue)
}
catch {
// ignore JSON.parse errors
}
return nextState
}
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
if (nextEditInJSON) {
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
? prev.value.filter(Boolean)
: undefined
nextState.value = nextValue
if (!prev.editorContent)
nextState.editorContent = JSON.stringify(nextValue)
return nextState
}
nextState.value = Array.isArray(prev.value) && prev.value.length ? prev.value : [undefined]
return nextState
}
if (prev.type === ChatVarType.ArrayBoolean && Array.isArray(prev.value) && nextEditInJSON)
nextState.editorContent = JSON.stringify(prev.value.map(item => item ? 'True' : 'False'))
return nextState
})
}
const handleEditorValueChange = (content: string) => {
setState((prev) => {
const nextState: VariableModalState = {
...prev,
editorContent: content,
}
if (!content) {
nextState.value = undefined
return nextState
}
try {
nextState.value = parseEditorContent({ content, type: prev.type })
}
catch {
// ignore JSON.parse errors
}
return nextState
})
}
const handleSave = () => {
if (!validateVariableName({ name: state.name, notify, t }))
return
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
notify({ type: 'error', message: 'name is existed' })
return
}
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
notify({ type: 'error', message: 'object key can not be empty' })
return
}
onSave({
description: state.description,
id: chatVar ? chatVar.id : uuid4(),
name: state.name,
value: formatChatVariableValue({
editInJSON: state.editInJSON,
objectValue: state.objectValue,
type: state.type,
value: state.value,
}),
value_type: state.type,
})
onClose()
}
return {
description: state.description,
editInJSON: state.editInJSON,
editorContent: state.editorContent,
editorMinHeight,
handleEditorChange,
handleEditorValueChange,
handleSave,
handleStringOrNumberChange,
handleTypeChange,
handleVarNameChange,
name: state.name,
objectValue: state.objectValue,
placeholder,
setDescription: (description: string) => setState(prev => ({ ...prev, description })),
setObjectValue: (objectValue: ObjectValueItem[]) => setState(prev => ({ ...prev, objectValue })),
setValue: (value: unknown) => setState(prev => ({ ...prev, value })),
type: state.type,
value: state.value,
}
}

View File

@@ -0,0 +1,170 @@
import type { ReactNode } from 'react'
import type { ChatVarType } from '../type'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
import { ChatVarType as ChatVarTypeEnum } from '../type'
import {
arrayBoolPlaceholder,
arrayNumberPlaceholder,
arrayObjectPlaceholder,
arrayStringPlaceholder,
objectPlaceholder,
} from '../utils'
import { DEFAULT_OBJECT_VALUE } from './object-value-item'
export type ObjectValueItem = {
key: string
type: ChatVarType
value: string | number | undefined
}
export type ToastPayload = {
type?: 'success' | 'error' | 'warning' | 'info'
size?: 'md' | 'sm'
duration?: number
message: string
children?: ReactNode
onClose?: () => void
className?: string
customComponent?: ReactNode
}
export const typeList = [
ChatVarTypeEnum.String,
ChatVarTypeEnum.Number,
ChatVarTypeEnum.Boolean,
ChatVarTypeEnum.Object,
ChatVarTypeEnum.ArrayString,
ChatVarTypeEnum.ArrayNumber,
ChatVarTypeEnum.ArrayBoolean,
ChatVarTypeEnum.ArrayObject,
]
export const getEditorMinHeight = (type: ChatVarType) =>
type === ChatVarTypeEnum.ArrayObject ? '240px' : '120px'
export const getPlaceholderByType = (type: ChatVarType) => {
if (type === ChatVarTypeEnum.ArrayString)
return arrayStringPlaceholder
if (type === ChatVarTypeEnum.ArrayNumber)
return arrayNumberPlaceholder
if (type === ChatVarTypeEnum.ArrayObject)
return arrayObjectPlaceholder
if (type === ChatVarTypeEnum.ArrayBoolean)
return arrayBoolPlaceholder
return objectPlaceholder
}
export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectValueItem[] => {
if (!chatVar || !chatVar.value || Object.keys(chatVar.value).length === 0)
return [DEFAULT_OBJECT_VALUE]
return Object.keys(chatVar.value).map((key) => {
const itemValue = chatVar.value[key]
return {
key,
type: typeof itemValue === 'string' ? ChatVarTypeEnum.String : ChatVarTypeEnum.Number,
value: itemValue,
}
})
}
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
return acc
}, {})
}
export const formatChatVariableValue = ({
editInJSON,
objectValue,
type,
value,
}: {
editInJSON: boolean
objectValue: ObjectValueItem[]
type: ChatVarType
value: unknown
}) => {
switch (type) {
case ChatVarTypeEnum.String:
return value || ''
case ChatVarTypeEnum.Number:
return value || 0
case ChatVarTypeEnum.Boolean:
return value === undefined ? true : value
case ChatVarTypeEnum.Object:
return editInJSON ? value : formatObjectValueFromList(objectValue)
case ChatVarTypeEnum.ArrayString:
case ChatVarTypeEnum.ArrayNumber:
case ChatVarTypeEnum.ArrayObject:
return Array.isArray(value) ? value.filter(Boolean) : []
case ChatVarTypeEnum.ArrayBoolean:
return value || []
}
}
export const validateVariableName = ({
name,
notify,
t,
}: {
name: string
notify: (props: ToastPayload) => void
t: (key: string, options?: Record<string, unknown>) => string
}) => {
const { isValid, errorMessageKey } = checkKeys([name], false)
if (!isValid) {
notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }),
})
return false
}
return true
}
export const getTypeChangeState = (nextType: ChatVarType) => {
return {
editInJSON: nextType === ChatVarTypeEnum.ArrayObject,
editorContent: undefined as string | undefined,
objectValue: nextType === ChatVarTypeEnum.Object ? [DEFAULT_OBJECT_VALUE] : undefined,
value:
nextType === ChatVarTypeEnum.Boolean
? false
: nextType === ChatVarTypeEnum.ArrayBoolean
? [false]
: undefined,
}
}
export const parseEditorContent = ({
content,
type,
}: {
content: string
type: ChatVarType
}) => {
const parsed = JSON.parse(content)
if (type !== ChatVarTypeEnum.ArrayBoolean)
return parsed
return parsed
.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)
return true
if (item === 'False' || item === 'false' || item === false)
return false
return undefined
})
.filter((item?: boolean) => item !== undefined)
}
export const getEditorToggleLabelKey = (type: ChatVarType, editInJSON: boolean) => {
if (type === ChatVarTypeEnum.Object)
return editInJSON ? 'chatVariable.modal.editInForm' : 'chatVariable.modal.editInJSON'
return editInJSON ? 'chatVariable.modal.oneByOne' : 'chatVariable.modal.editInJSON'
}

View File

@@ -0,0 +1,217 @@
import type { ReactNode } from 'react'
import type { ObjectValueItem } from './variable-modal.helpers'
import { RiDraftLine, RiInputField } from '@remixicon/react'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChatVarType } from '../type'
import ArrayBoolList from './array-bool-list'
import ArrayValueList from './array-value-list'
import BoolValue from './bool-value'
import ObjectValueList from './object-value-list'
import VariableTypeSelector from './variable-type-select'
type SectionTitleProps = {
children: ReactNode
}
export const SectionTitle = ({ children }: SectionTitleProps) => (
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{children}</div>
)
type NameSectionProps = {
name: string
onBlur: (value: string) => void
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder: string
title: string
}
export const NameSection = ({
name,
onBlur,
onChange,
placeholder,
title,
}: NameSectionProps) => (
<div className="mb-4">
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<Input
placeholder={placeholder}
value={name}
onChange={onChange}
onBlur={e => onBlur(e.target.value)}
type="text"
/>
</div>
</div>
)
type TypeSectionProps = {
list: ChatVarType[]
onSelect: (value: ChatVarType) => void
title: string
type: ChatVarType
}
export const TypeSection = ({
list,
onSelect,
title,
type,
}: TypeSectionProps) => (
<div className="mb-4">
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<VariableTypeSelector
value={type}
list={list}
onSelect={onSelect}
popupClassName="w-[327px]"
/>
</div>
</div>
)
type ValueSectionProps = {
editorContent?: string
editorMinHeight: string
editInJSON: boolean
objectValue: ObjectValueItem[]
onArrayBoolChange: (value: boolean[]) => void
onArrayChange: (value: Array<string | number | undefined>) => void
onEditorChange: (nextEditInJson: boolean) => void
onEditorValueChange: (content: string) => void
onObjectChange: (value: ObjectValueItem[]) => void
onValueChange: (value: boolean) => void
placeholder: ReactNode
t: (key: string, options?: Record<string, unknown>) => string
toggleLabelKey?: string
type: ChatVarType
value: unknown
}
export const ValueSection = ({
editorContent,
editorMinHeight,
editInJSON,
objectValue,
onArrayBoolChange,
onArrayChange,
onEditorChange,
onEditorValueChange,
onObjectChange,
onValueChange,
placeholder,
t,
toggleLabelKey,
type,
value,
}: ValueSectionProps) => (
<div className="mb-4">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{toggleLabelKey && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => onEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{t(toggleLabelKey, { ns: 'workflow' })}
</Button>
)}
</div>
<div className="flex">
{type === ChatVarType.String && (
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={(value as string) || ''}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => onArrayChange([e.target.value])}
/>
)}
{type === ChatVarType.Number && (
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value as number | undefined}
onChange={e => onArrayChange([Number(e.target.value)])}
type="number"
/>
)}
{type === ChatVarType.Boolean && (
<BoolValue
value={value as boolean}
onChange={onValueChange}
/>
)}
{type === ChatVarType.Object && !editInJSON && (
<ObjectValueList
list={objectValue}
onChange={onObjectChange}
/>
)}
{type === ChatVarType.ArrayString && !editInJSON && (
<ArrayValueList
isString
list={(value as Array<string | undefined>) || [undefined]}
onChange={onArrayChange}
/>
)}
{type === ChatVarType.ArrayNumber && !editInJSON && (
<ArrayValueList
isString={false}
list={(value as Array<number | undefined>) || [undefined]}
onChange={onArrayChange}
/>
)}
{type === ChatVarType.ArrayBoolean && !editInJSON && (
<ArrayBoolList
list={(value as boolean[]) || [true]}
onChange={onArrayBoolChange}
/>
)}
{editInJSON && (
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
<CodeEditor
isExpand
noWrapper
language={CodeLanguage.json}
value={editorContent}
placeholder={<div className="whitespace-pre">{placeholder}</div>}
onChange={onEditorValueChange}
/>
</div>
)}
</div>
</div>
)
type DescriptionSectionProps = {
description: string
onChange: (value: string) => void
placeholder: string
title: string
}
export const DescriptionSection = ({
description,
onChange,
placeholder,
title,
}: DescriptionSectionProps) => (
<div>
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={placeholder}
onChange={e => onChange(e.target.value)}
/>
</div>
</div>
)

View File

@@ -1,32 +1,26 @@
import type { ConversationVariable } from '@/app/components/workflow/types'
import { RiCloseLine, RiDraftLine, RiInputField } from '@remixicon/react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { v4 as uuid4 } from 'uuid'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { ToastContext } from '@/app/components/base/toast/context'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import ArrayValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-value-list'
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
import ObjectValueList from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-list'
import VariableTypeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/variable-type-select'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import {
arrayBoolPlaceholder,
arrayNumberPlaceholder,
arrayObjectPlaceholder,
arrayStringPlaceholder,
objectPlaceholder,
} from '@/app/components/workflow/panel/chat-variable-panel/utils'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ArrayBoolList from './array-bool-list'
import BoolValue from './bool-value'
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import { useVariableModalState } from './use-variable-modal-state'
import {
getEditorToggleLabelKey,
typeList,
validateVariableName,
} from './variable-modal.helpers'
import {
DescriptionSection,
NameSection,
TypeSection,
ValueSection,
} from './variable-modal.sections'
export type ModalPropsType = {
chatVar?: ConversationVariable
@@ -34,23 +28,6 @@ export type ModalPropsType = {
onSave: (chatVar: ConversationVariable) => void
}
type ObjectValueItem = {
key: string
type: ChatVarType
value: string | number | undefined
}
const typeList = [
ChatVarType.String,
ChatVarType.Number,
ChatVarType.Boolean,
ChatVarType.Object,
ChatVarType.ArrayString,
ChatVarType.ArrayNumber,
ChatVarType.ArrayBoolean,
ChatVarType.ArrayObject,
]
const ChatVariableModal = ({
chatVar,
onClose,
@@ -59,216 +36,41 @@ const ChatVariableModal = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const workflowStore = useWorkflowStore()
const [name, setName] = React.useState('')
const [type, setType] = React.useState<ChatVarType>(ChatVarType.String)
const [value, setValue] = React.useState<any>()
const [objectValue, setObjectValue] = React.useState<ObjectValueItem[]>([DEFAULT_OBJECT_VALUE])
const [editorContent, setEditorContent] = React.useState<string>()
const [editInJSON, setEditInJSON] = React.useState(false)
const [description, setDescription] = React.useState<string>('')
const {
description,
editInJSON,
editorContent,
editorMinHeight,
handleEditorChange,
handleEditorValueChange,
handleSave,
handleStringOrNumberChange,
handleTypeChange,
handleVarNameChange,
name,
objectValue,
placeholder,
setDescription,
setObjectValue,
setValue,
type,
value,
} = useVariableModalState({
chatVar,
conversationVariables: workflowStore.getState().conversationVariables,
notify,
onClose,
onSave,
t,
})
const editorMinHeight = useMemo(() => {
if (type === ChatVarType.ArrayObject)
return '240px'
return '120px'
}, [type])
const placeholder = useMemo(() => {
if (type === ChatVarType.ArrayString)
return arrayStringPlaceholder
if (type === ChatVarType.ArrayNumber)
return arrayNumberPlaceholder
if (type === ChatVarType.ArrayObject)
return arrayObjectPlaceholder
if (type === ChatVarType.ArrayBoolean)
return arrayBoolPlaceholder
return objectPlaceholder
}, [type])
const getObjectValue = useCallback(() => {
if (!chatVar || Object.keys(chatVar.value).length === 0)
return [DEFAULT_OBJECT_VALUE]
return Object.keys(chatVar.value).map((key) => {
return {
key,
type: typeof chatVar.value[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: chatVar.value[key],
}
})
}, [chatVar])
const formatValueFromObject = useCallback((list: ObjectValueItem[]) => {
return list.reduce((acc: any, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
return acc
}, {})
}, [])
const formatValue = (value: any) => {
switch (type) {
case ChatVarType.String:
return value || ''
case ChatVarType.Number:
return value || 0
case ChatVarType.Boolean:
return value === undefined ? true : value
case ChatVarType.Object:
return editInJSON ? value : formatValueFromObject(objectValue)
case ChatVarType.ArrayString:
case ChatVarType.ArrayNumber:
case ChatVarType.ArrayObject:
return value?.filter(Boolean) || []
case ChatVarType.ArrayBoolean:
return value || []
}
}
const checkVariableName = (value: string) => {
const { isValid, errorMessageKey } = checkKeys([value], false)
if (!isValid) {
notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }),
})
return false
}
return true
}
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
replaceSpaceWithUnderscoreInVarNameInput(e.target)
if (!!e.target.value && !checkVariableName(e.target.value))
if (e.target.value && !validateVariableName({ name: e.target.value, notify, t }))
return
setName(e.target.value || '')
handleVarNameChange(e)
}
const handleTypeChange = (v: ChatVarType) => {
setValue(undefined)
setEditorContent(undefined)
if (v === ChatVarType.ArrayObject)
setEditInJSON(true)
if (v === ChatVarType.String || v === ChatVarType.Number || v === ChatVarType.Object)
setEditInJSON(false)
if (v === ChatVarType.Boolean)
setValue(false)
if (v === ChatVarType.ArrayBoolean)
setValue([false])
setType(v)
}
const handleEditorChange = (editInJSON: boolean) => {
if (type === ChatVarType.Object) {
if (editInJSON) {
const newValue = !objectValue[0].key ? undefined : formatValueFromObject(objectValue)
setValue(newValue)
setEditorContent(JSON.stringify(newValue))
}
else {
if (!editorContent) {
setValue(undefined)
setObjectValue([DEFAULT_OBJECT_VALUE])
}
else {
try {
const newValue = JSON.parse(editorContent)
setValue(newValue)
const newObjectValue = Object.keys(newValue).map((key) => {
return {
key,
type: typeof newValue[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: newValue[key],
}
})
setObjectValue(newObjectValue)
}
catch {
// ignore JSON.parse errors
}
}
}
}
if (type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber) {
if (editInJSON) {
const newValue = (value?.length && value.filter(Boolean).length) ? value.filter(Boolean) : undefined
setValue(newValue)
if (!editorContent)
setEditorContent(JSON.stringify(newValue))
}
else {
setValue(value?.length ? value : [undefined])
}
}
if (type === ChatVarType.ArrayBoolean) {
if (editInJSON)
setEditorContent(JSON.stringify(value.map((item: boolean) => item ? 'True' : 'False')))
}
setEditInJSON(editInJSON)
}
const handleEditorValueChange = (content: string) => {
if (!content) {
setEditorContent(content)
return setValue(undefined)
}
else {
setEditorContent(content)
try {
let newValue = JSON.parse(content)
if (type === ChatVarType.ArrayBoolean) {
newValue = newValue.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)
return true
if (item === 'False' || item === 'false' || item === false)
return false
return undefined
}).filter((item?: boolean) => item !== undefined)
}
setValue(newValue)
}
catch {
// ignore JSON.parse errors
}
}
}
const handleSave = () => {
if (!checkVariableName(name))
return
const varList = workflowStore.getState().conversationVariables
if (!chatVar && varList.some(chatVar => chatVar.name === name))
return notify({ type: 'error', message: 'name is existed' })
// if (type !== ChatVarType.Object && !value)
// return notify({ type: 'error', message: 'value can not be empty' })
if (type === ChatVarType.Object && objectValue.some(item => !item.key && !!item.value))
return notify({ type: 'error', message: 'object key can not be empty' })
onSave({
id: chatVar ? chatVar.id : uuid4(),
name,
value_type: type,
value: formatValue(value),
description,
})
onClose()
}
useEffect(() => {
if (chatVar) {
setName(chatVar.name)
setType(chatVar.value_type)
setValue(chatVar.value)
setDescription(chatVar.description)
setObjectValue(getObjectValue())
if (chatVar.value_type === ChatVarType.ArrayObject) {
setEditorContent(JSON.stringify(chatVar.value))
setEditInJSON(true)
}
else {
setEditInJSON(false)
}
}
}, [chatVar, getObjectValue])
return (
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
@@ -285,135 +87,49 @@ const ChatVariableModal = ({
</div>
</div>
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
{/* name */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="flex">
<Input
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
value={name}
onChange={handleVarNameChange}
onBlur={e => checkVariableName(e.target.value)}
type="text"
/>
</div>
</div>
{/* type */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="flex">
<VariableTypeSelector
value={type}
list={typeList}
onSelect={handleTypeChange}
popupClassName="w-[327px]"
/>
</div>
</div>
{/* default value */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => handleEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{editInJSON ? t('chatVariable.modal.oneByOne', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
</Button>
)}
{type === ChatVarType.Object && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => handleEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{editInJSON ? t('chatVariable.modal.editInForm', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
</Button>
)}
</div>
<div className="flex">
{type === ChatVarType.String && (
// Input will remove \n\r, so use Textarea just like description area
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setValue(e.target.value)}
/>
)}
{type === ChatVarType.Number && (
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value}
onChange={e => setValue(Number(e.target.value))}
type="number"
/>
)}
{type === ChatVarType.Boolean && (
<BoolValue
value={value}
onChange={setValue}
/>
)}
{type === ChatVarType.Object && !editInJSON && (
<ObjectValueList
list={objectValue}
onChange={setObjectValue}
/>
)}
{type === ChatVarType.ArrayString && !editInJSON && (
<ArrayValueList
isString
list={value || [undefined]}
onChange={setValue}
/>
)}
{type === ChatVarType.ArrayNumber && !editInJSON && (
<ArrayValueList
isString={false}
list={value || [undefined]}
onChange={setValue}
/>
)}
{type === ChatVarType.ArrayBoolean && !editInJSON && (
<ArrayBoolList
list={value || [true]}
onChange={setValue}
/>
)}
{editInJSON && (
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
<CodeEditor
isExpand
noWrapper
language={CodeLanguage.json}
value={editorContent}
placeholder={<div className="whitespace-pre">{placeholder}</div>}
onChange={handleEditorValueChange}
/>
</div>
)}
</div>
</div>
{/* description */}
<div className="">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="flex">
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
<NameSection
name={name}
onBlur={nextName => validateVariableName({ name: nextName, notify, t })}
onChange={handleNameChange}
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
title={t('chatVariable.modal.name', { ns: 'workflow' })}
/>
<TypeSection
type={type}
list={typeList}
onSelect={handleTypeChange}
title={t('chatVariable.modal.type', { ns: 'workflow' })}
/>
<ValueSection
type={type}
value={value}
objectValue={objectValue}
editInJSON={editInJSON}
editorContent={editorContent}
editorMinHeight={editorMinHeight}
onArrayBoolChange={setValue}
onArrayChange={type === ChatVarType.String || type === ChatVarType.Number ? handleStringOrNumberChange : setValue}
onEditorChange={handleEditorChange}
onEditorValueChange={handleEditorValueChange}
onObjectChange={setObjectValue}
onValueChange={setValue}
placeholder={placeholder}
t={t}
toggleLabelKey={
type === ChatVarType.Object
|| type === ChatVarType.ArrayString
|| type === ChatVarType.ArrayNumber
|| type === ChatVarType.ArrayBoolean
? getEditorToggleLabelKey(type, editInJSON)
: undefined
}
/>
<DescriptionSection
description={description}
onChange={setDescription}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
title={t('chatVariable.modal.description', { ns: 'workflow' })}
/>
</div>
<div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2">
<div className="flex gap-2">

View File

@@ -8913,12 +8913,6 @@
"app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 8
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx": {