diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx new file mode 100644 index 0000000000..b5015ed079 --- /dev/null +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -0,0 +1,394 @@ +import type { ReactNode } from 'react' +import type { IConfigVarProps } from './index' +import type { ExternalDataTool } from '@/models/common' +import type { PromptVariable } from '@/models/debug' +import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { vi } from 'vitest' +import Toast from '@/app/components/base/toast' +import DebugConfigurationContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' + +import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index' + +const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn()) + +const setShowExternalDataToolModal = vi.fn() + +type SubscriptionEvent = { + type: string + payload: ExternalDataTool +} + +let subscriptionCallback: ((event: SubscriptionEvent) => void) | null = null + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: (callback: (event: SubscriptionEvent) => void) => { + subscriptionCallback = callback + }, + }, + }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalDataToolModal, + }), +})) + +type SortableItem = { + id: string + variable: PromptVariable +} + +type SortableProps = { + list: SortableItem[] + setList: (list: SortableItem[]) => void + children: ReactNode +} + +let latestSortableProps: SortableProps | null = null + +vi.mock('react-sortablejs', () => ({ + ReactSortable: (props: SortableProps) => { + latestSortableProps = props + return
{props.children}
+ }, +})) + +type DebugConfigurationState = React.ComponentProps['value'] + +const defaultDebugConfigValue = { + mode: AppModeEnum.CHAT, + dataSets: [], + modelConfig: { + model_id: 'test-model', + }, +} as unknown as DebugConfigurationState + +const createDebugConfigValue = (overrides: Partial = {}): DebugConfigurationState => ({ + ...defaultDebugConfigValue, + ...overrides, +} as unknown as DebugConfigurationState) + +let variableIndex = 0 +const createPromptVariable = (overrides: Partial = {}): PromptVariable => { + variableIndex += 1 + return { + key: `var_${variableIndex}`, + name: `Variable ${variableIndex}`, + type: 'string', + required: false, + ...overrides, + } +} + +const renderConfigVar = (props: Partial = {}, debugOverrides: Partial = {}) => { + const defaultProps: IConfigVarProps = { + promptVariables: [], + readonly: false, + onPromptVariablesChange: vi.fn(), + } + + const mergedProps = { + ...defaultProps, + ...props, + } + + return render( + + + , + ) +} + +describe('ConfigVar', () => { + // Rendering behavior for empty and populated states. + describe('ConfigVar Rendering', () => { + beforeEach(() => { + vi.clearAllMocks() + latestSortableProps = null + subscriptionCallback = null + variableIndex = 0 + notifySpy.mockClear() + }) + + it('should show empty state when no variables exist', () => { + renderConfigVar({ promptVariables: [] }) + + expect(screen.getByText('appDebug.notSetVar')).toBeInTheDocument() + }) + + it('should render variable items and allow reordering via sortable list', () => { + const onPromptVariablesChange = vi.fn() + const firstVar = createPromptVariable({ key: 'first', name: 'First' }) + const secondVar = createPromptVariable({ key: 'second', name: 'Second' }) + + renderConfigVar({ + promptVariables: [firstVar, secondVar], + onPromptVariablesChange, + }) + + expect(screen.getByText('first')).toBeInTheDocument() + expect(screen.getByText('second')).toBeInTheDocument() + + act(() => { + latestSortableProps?.setList([ + { id: 'second', variable: secondVar }, + { id: 'first', variable: firstVar }, + ]) + }) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar]) + }) + }) + + // Variable creation flows using the add menu. + describe('ConfigVar Add Variable', () => { + beforeEach(() => { + vi.clearAllMocks() + latestSortableProps = null + subscriptionCallback = null + variableIndex = 0 + notifySpy.mockClear() + }) + + it('should add a text variable when selecting the string option', async () => { + const onPromptVariablesChange = vi.fn() + renderConfigVar({ promptVariables: [], onPromptVariablesChange }) + + fireEvent.click(screen.getByText('common.operation.add')) + fireEvent.click(await screen.findByText('appDebug.variableConfig.string')) + + expect(onPromptVariablesChange).toHaveBeenCalledTimes(1) + const [nextVariables] = onPromptVariablesChange.mock.calls[0] + expect(nextVariables).toHaveLength(1) + expect(nextVariables[0].type).toBe('string') + }) + + it('should open the external data tool modal when adding an api variable', async () => { + const onPromptVariablesChange = vi.fn() + renderConfigVar({ promptVariables: [], onPromptVariablesChange }) + + fireEvent.click(screen.getByText('common.operation.add')) + fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar')) + + expect(onPromptVariablesChange).toHaveBeenCalledTimes(1) + expect(setShowExternalDataToolModal).toHaveBeenCalledTimes(1) + + const modalState = setShowExternalDataToolModal.mock.calls[0][0] + expect(modalState.payload.type).toBe('api') + + act(() => { + modalState.onCancelCallback?.() + }) + + expect(onPromptVariablesChange).toHaveBeenLastCalledWith([]) + }) + + it('should restore previous variables when cancelling api variable with existing items', async () => { + const onPromptVariablesChange = vi.fn() + const existingVar = createPromptVariable({ key: 'existing', name: 'Existing' }) + + renderConfigVar({ promptVariables: [existingVar], onPromptVariablesChange }) + + fireEvent.click(screen.getByText('common.operation.add')) + fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar')) + + const modalState = setShowExternalDataToolModal.mock.calls[0][0] + act(() => { + modalState.onCancelCallback?.() + }) + + expect(onPromptVariablesChange).toHaveBeenCalledTimes(2) + const [addedVariables] = onPromptVariablesChange.mock.calls[0] + expect(addedVariables).toHaveLength(2) + expect(addedVariables[0]).toBe(existingVar) + expect(addedVariables[1].type).toBe('api') + expect(onPromptVariablesChange).toHaveBeenLastCalledWith([existingVar]) + }) + }) + + // Editing flows for variables through the modal. + describe('ConfigVar Edit Variable', () => { + beforeEach(() => { + vi.clearAllMocks() + latestSortableProps = null + subscriptionCallback = null + variableIndex = 0 + notifySpy.mockClear() + }) + + it('should save updates when editing a basic variable', async () => { + const onPromptVariablesChange = vi.fn() + const variable = createPromptVariable({ key: 'name', name: 'Name' }) + + renderConfigVar({ + promptVariables: [variable], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('name · Name') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + expect(actionButtons).toHaveLength(2) + fireEvent.click(actionButtons[0]) + + const saveButton = await screen.findByRole('button', { name: 'common.operation.save' }) + fireEvent.click(saveButton) + + expect(onPromptVariablesChange).toHaveBeenCalledTimes(1) + }) + + it('should show error when variable key is duplicated', async () => { + const onPromptVariablesChange = vi.fn() + const firstVar = createPromptVariable({ key: 'first', name: 'First' }) + const secondVar = createPromptVariable({ key: 'second', name: 'Second' }) + + renderConfigVar({ + promptVariables: [firstVar, secondVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('first · First') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + expect(actionButtons).toHaveLength(2) + fireEvent.click(actionButtons[0]) + + const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') + fireEvent.change(inputs[0], { target: { value: 'second' } }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(Toast.notify).toHaveBeenCalled() + expect(onPromptVariablesChange).not.toHaveBeenCalled() + }) + + it('should show error when variable label is duplicated', async () => { + const onPromptVariablesChange = vi.fn() + const firstVar = createPromptVariable({ key: 'first', name: 'First' }) + const secondVar = createPromptVariable({ key: 'second', name: 'Second' }) + + renderConfigVar({ + promptVariables: [firstVar, secondVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('first · First') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + expect(actionButtons).toHaveLength(2) + fireEvent.click(actionButtons[0]) + + const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') + fireEvent.change(inputs[1], { target: { value: 'Second' } }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(Toast.notify).toHaveBeenCalled() + expect(onPromptVariablesChange).not.toHaveBeenCalled() + }) + }) + + // Removal behavior including confirm modal branch. + describe('ConfigVar Remove Variable', () => { + beforeEach(() => { + vi.clearAllMocks() + latestSortableProps = null + subscriptionCallback = null + variableIndex = 0 + notifySpy.mockClear() + }) + + it('should remove variable directly when context confirmation is not required', () => { + const onPromptVariablesChange = vi.fn() + const variable = createPromptVariable({ key: 'name', name: 'Name' }) + + renderConfigVar({ + promptVariables: [variable], + onPromptVariablesChange, + }) + + const removeBtn = screen.getByTestId('var-item-delete-btn') + fireEvent.click(removeBtn) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([]) + }) + + it('should require confirmation when removing context variable with datasets in completion mode', () => { + const onPromptVariablesChange = vi.fn() + const variable = createPromptVariable({ + key: 'context', + name: 'Context', + is_context_var: true, + }) + + renderConfigVar( + { + promptVariables: [variable], + onPromptVariablesChange, + }, + { + mode: AppModeEnum.COMPLETION, + dataSets: [{ id: 'dataset-1' } as DebugConfigurationState['dataSets'][number]], + }, + ) + + const deleteBtn = screen.getByTestId('var-item-delete-btn') + fireEvent.click(deleteBtn) + // confirmation modal should show up + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([]) + }) + }) + + // Event subscription support for external data tools. + describe('ConfigVar External Data Tool Events', () => { + beforeEach(() => { + vi.clearAllMocks() + latestSortableProps = null + subscriptionCallback = null + variableIndex = 0 + notifySpy.mockClear() + }) + + it('should append external data tool variables from event emitter', () => { + const onPromptVariablesChange = vi.fn() + renderConfigVar({ + promptVariables: [], + onPromptVariablesChange, + }) + + act(() => { + subscriptionCallback?.({ + type: ADD_EXTERNAL_DATA_TOOL, + payload: { + variable: 'api_var', + label: 'API Var', + enabled: true, + type: 'api', + config: {}, + icon: 'icon', + icon_background: 'bg', + }, + }) + }) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + key: 'api_var', + name: 'API Var', + required: true, + type: 'api', + }), + ]) + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index b26664401b..4a38fc92a6 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -3,10 +3,11 @@ import type { FC } from 'react' import type { InputVar } from '@/app/components/workflow/types' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' +import type { I18nKeysByPrefix } from '@/types/i18n' import { useBoolean } from 'ahooks' import { produce } from 'immer' import * as React from 'react' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' import { useContext } from 'use-context-selector' @@ -33,11 +34,55 @@ type ExternalDataToolParams = { type: string index: number name: string - config?: Record + config?: PromptVariable['config'] icon?: string icon_background?: string } +const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox']) + +const toInputVar = (item: PromptVariable): InputVar => ({ + ...item, + label: item.name, + variable: item.key, + type: (item.type === 'string' ? InputVarType.textInput : item.type) as InputVarType, + required: item.required ?? false, +}) + +const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => { + const { variable, label, type, ...rest } = payload + const nextType = type === InputVarType.textInput ? 'string' : type + const nextItem: PromptVariable = { + ...rest, + type: nextType, + key: variable, + name: label as string, + } + if (payload.type === InputVarType.textInput) + nextItem.max_length = nextItem.max_length || DEFAULT_VALUE_MAX_LEN + + if (payload.type !== InputVarType.select) + delete nextItem.options + + return nextItem +} + +const getDuplicateError = (list: PromptVariable[]) => { + if (hasDuplicateStr(list.map(item => item.key))) { + return { + errorMsgKey: 'varKeyError.keyAlreadyExists', + typeName: 'variableConfig.varName', + } + } + if (hasDuplicateStr(list.map(item => item.name as string))) { + return { + errorMsgKey: 'varKeyError.keyAlreadyExists', + typeName: 'variableConfig.labelName', + } + } + return null +} + export type IConfigVarProps = { promptVariables: PromptVariable[] readonly?: boolean @@ -55,61 +100,31 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar const hasVar = promptVariables.length > 0 const [currIndex, setCurrIndex] = useState(-1) const currItem = currIndex !== -1 ? promptVariables[currIndex] : null - const currItemToEdit: InputVar | null = (() => { + const currItemToEdit = useMemo(() => { if (!currItem) return null - - return { - ...currItem, - label: currItem.name, - variable: currItem.key, - type: currItem.type === 'string' ? InputVarType.textInput : currItem.type, - } as InputVar - })() - const updatePromptVariableItem = (payload: InputVar) => { + return toInputVar(currItem) + }, [currItem]) + const updatePromptVariableItem = useCallback((payload: InputVar) => { const newPromptVariables = produce(promptVariables, (draft) => { - const { variable, label, type, ...rest } = payload - draft[currIndex] = { - ...rest, - type: type === InputVarType.textInput ? 'string' : type, - key: variable, - name: label as string, - } - - if (payload.type === InputVarType.textInput) - draft[currIndex].max_length = draft[currIndex].max_length || DEFAULT_VALUE_MAX_LEN - - if (payload.type !== InputVarType.select) - delete draft[currIndex].options + draft[currIndex] = buildPromptVariableFromInput(payload) }) - - const newList = newPromptVariables - let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = '' - let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = '' - if (hasDuplicateStr(newList.map(item => item.key))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.varName' - } - else if (hasDuplicateStr(newList.map(item => item.name as string))) { - errorMsgKey = 'varKeyError.keyAlreadyExists' - typeName = 'variableConfig.labelName' - } - - if (errorMsgKey && typeName) { + const duplicateError = getDuplicateError(newPromptVariables) + if (duplicateError) { Toast.notify({ type: 'error', - message: t(errorMsgKey, { ns: 'appDebug', key: t(typeName, { ns: 'appDebug' }) }), + message: t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string, }) return false } onPromptVariablesChange?.(newPromptVariables) return true - } + }, [currIndex, onPromptVariablesChange, promptVariables, t]) const { setShowExternalDataToolModal } = useModalContext() - const handleOpenExternalDataToolModal = ( + const handleOpenExternalDataToolModal = useCallback(( { key, type, index, name, config, icon, icon_background }: ExternalDataToolParams, oldPromptVariables: PromptVariable[], ) => { @@ -157,9 +172,9 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar return true }, }) - } + }, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t]) - const handleAddVar = (type: string) => { + const handleAddVar = useCallback((type: string) => { const newVar = getNewVar('', type) const newPromptVariables = [...promptVariables, newVar] onPromptVariablesChange?.(newPromptVariables) @@ -172,8 +187,9 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar index: promptVariables.length, }, newPromptVariables) } - } + }, [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables]) + // eslint-disable-next-line ts/no-explicit-any eventEmitter?.useSubscription((v: any) => { if (v.type === ADD_EXTERNAL_DATA_TOOL) { const payload = v.payload @@ -195,11 +211,11 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false) const [removeIndex, setRemoveIndex] = useState(null) - const didRemoveVar = (index: number) => { + const didRemoveVar = useCallback((index: number) => { onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) - } + }, [onPromptVariablesChange, promptVariables]) - const handleRemoveVar = (index: number) => { + const handleRemoveVar = useCallback((index: number) => { const removeVar = promptVariables[index] if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) { @@ -208,21 +224,20 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar return } didRemoveVar(index) - } + }, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal]) - // const [currKey, setCurrKey] = useState(null) const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) - const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { + const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { // setCurrKey(key) setCurrIndex(index) - if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number' && type !== 'checkbox') { + if (!BASIC_INPUT_TYPES.has(type)) { handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables) return } showEditModal() - } + }, [handleOpenExternalDataToolModal, promptVariables, showEditModal]) const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => { return { diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index a4888db628..1fc21e3d33 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -65,6 +65,7 @@ const VarItem: FC = ({
setIsDeleting(true)}