mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 20:22:39 +08:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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">
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user