diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index a36cfa3255..e2e001c5b4 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -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('../hooks') + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), + } +}) + +vi.mock('../selection-contextmenu.helpers', async () => { + const actual = await vi.importActual('../selection-contextmenu.helpers') + return { + ...actual, + alignNodePosition: (...args: Parameters) => { + if (mockAlignNodePosition.getMockImplementation()) + return mockAlignNodePosition(...args) + return actual.alignNodePosition(...args) + }, + getAlignableNodes: (...args: Parameters) => { + if (mockGetAlignableNodes.getMockImplementation()) + return mockGetAlignableNodes(...args) + return actual.getAlignableNodes(...args) + }, + getAlignBounds: (...args: Parameters) => { + 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) + }) }) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx index efa54ab150..6dda819a04 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -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() 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( + , + ) + + 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( + , + ) + + expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender( + , + ) + 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( + , + ) + + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('hidden')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx index 0298a05848..a7f88e983e 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -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: () =>
, })) @@ -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( + +
Loop body
+
, + ) + + expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2') + expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts new file mode 100644 index 0000000000..61ad609e50 --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts @@ -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[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) + 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) + 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() + }) +}) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts new file mode 100644 index 0000000000..9e082265d6 --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts @@ -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', + })) + }) +}) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx new file mode 100644 index 0000000000..5774b83fec --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx @@ -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>) => { + 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: ( + + ), + }, + ), + ) + + 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: '', + }) + }) +}) diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts new file mode 100644 index 0000000000..ecc8af6432 --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts @@ -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 +} + +type VariableModalState = { + description: string + editInJSON: boolean + editorContent?: string + name: string + objectValue: ObjectValueItem[] + type: ChatVarType + value: unknown +} + +const buildObjectValueListFromRecord = (record: Record) => { + 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(() => buildInitialState(chatVar)) + + const editorMinHeight = useMemo(() => getEditorMinHeight(state.type), [state.type]) + const placeholder = useMemo(() => getPlaceholderByType(state.type), [state.type]) + + const handleVarNameChange = (e: React.ChangeEvent) => { + 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) => { + 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 + 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, + } +} diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts new file mode 100644 index 0000000000..944b197e19 --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts @@ -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>((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 +}) => { + 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' +} diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx new file mode 100644 index 0000000000..dd7e69bb34 --- /dev/null +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx @@ -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) => ( +
{children}
+) + +type NameSectionProps = { + name: string + onBlur: (value: string) => void + onChange: (e: React.ChangeEvent) => void + placeholder: string + title: string +} + +export const NameSection = ({ + name, + onBlur, + onChange, + placeholder, + title, +}: NameSectionProps) => ( +
+ {title} +
+ onBlur(e.target.value)} + type="text" + /> +
+
+) + +type TypeSectionProps = { + list: ChatVarType[] + onSelect: (value: ChatVarType) => void + title: string + type: ChatVarType +} + +export const TypeSection = ({ + list, + onSelect, + title, + type, +}: TypeSectionProps) => ( +
+ {title} +
+ +
+
+) + +type ValueSectionProps = { + editorContent?: string + editorMinHeight: string + editInJSON: boolean + objectValue: ObjectValueItem[] + onArrayBoolChange: (value: boolean[]) => void + onArrayChange: (value: Array) => 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 + 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) => ( +
+
+
{t('chatVariable.modal.value', { ns: 'workflow' })}
+ {toggleLabelKey && ( + + )} +
+
+ {type === ChatVarType.String && ( +