diff --git a/web/app/components/app/configuration/config-var/__tests__/helpers.spec.ts b/web/app/components/app/configuration/config-var/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..6e46c820eb --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/helpers.spec.ts @@ -0,0 +1,122 @@ +import type { InputVar } from '@/app/components/workflow/types' +import type { ExternalDataTool } from '@/models/common' +import type { PromptVariable } from '@/models/debug' +import { describe, expect, it } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' +import { + buildPromptVariableFromExternalDataTool, + buildPromptVariableFromInput, + createPromptVariablesWithIds, + getDuplicateError, + toInputVar, +} from '../helpers' + +const createPromptVariable = (overrides: Partial = {}): PromptVariable => ({ + key: 'var_1', + name: 'Variable 1', + required: false, + type: 'string', + ...overrides, +}) + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + label: 'Variable 1', + required: false, + type: InputVarType.textInput, + variable: 'var_1', + ...overrides, +}) + +const createExternalDataTool = (overrides: Partial = {}): ExternalDataTool => ({ + config: { region: 'us' }, + enabled: true, + icon: 'icon', + icon_background: '#000', + label: 'External Tool', + type: 'api', + variable: 'external_tool', + ...overrides, +}) + +describe('config-var/helpers', () => { + it('should convert prompt variables into input vars', () => { + expect(toInputVar(createPromptVariable())).toEqual(expect.objectContaining({ + label: 'Variable 1', + required: false, + type: InputVarType.textInput, + variable: 'var_1', + })) + + expect(toInputVar(createPromptVariable({ + required: undefined, + type: 'select', + }))).toEqual(expect.objectContaining({ + required: false, + type: 'select', + })) + }) + + it('should build prompt variables from input vars', () => { + expect(buildPromptVariableFromInput(createInputVar())).toEqual(expect.objectContaining({ + key: 'var_1', + name: 'Variable 1', + type: 'string', + })) + + expect(buildPromptVariableFromInput(createInputVar({ + options: ['One'], + type: InputVarType.select, + }))).toEqual(expect.objectContaining({ + options: ['One'], + type: InputVarType.select, + })) + + expect(buildPromptVariableFromInput(createInputVar({ + options: ['One'], + type: InputVarType.number, + }))).not.toHaveProperty('options') + }) + + it('should detect duplicate keys and labels', () => { + expect(getDuplicateError([ + createPromptVariable({ key: 'same', name: 'First' }), + createPromptVariable({ key: 'same', name: 'Second' }), + ])).toEqual({ + errorMsgKey: 'varKeyError.keyAlreadyExists', + typeName: 'variableConfig.varName', + }) + + expect(getDuplicateError([ + createPromptVariable({ key: 'first', name: 'Same' }), + createPromptVariable({ key: 'second', name: 'Same' }), + ])).toEqual({ + errorMsgKey: 'varKeyError.keyAlreadyExists', + typeName: 'variableConfig.labelName', + }) + + expect(getDuplicateError([ + createPromptVariable({ key: 'first', name: 'First' }), + createPromptVariable({ key: 'second', name: 'Second' }), + ])).toBeNull() + }) + + it('should build prompt variables from external data tools and assign ids', () => { + const tool = createExternalDataTool() + expect(buildPromptVariableFromExternalDataTool(tool, true)).toEqual(expect.objectContaining({ + config: { region: 'us' }, + enabled: true, + key: 'external_tool', + name: 'External Tool', + required: true, + type: 'api', + })) + + expect(createPromptVariablesWithIds([ + createPromptVariable({ key: 'first' }), + createPromptVariable({ key: 'second' }), + ])).toEqual([ + { id: 'first', variable: expect.objectContaining({ key: 'first' }) }, + { id: 'second', variable: expect.objectContaining({ key: 'second' }) }, + ]) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx new file mode 100644 index 0000000000..d3dafff5ed --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx @@ -0,0 +1,13 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import InputTypeIcon from '../input-type-icon' + +describe('InputTypeIcon', () => { + it('should render icons for supported variable types', () => { + const { container, rerender } = render() + expect(container.querySelector('svg')).toBeTruthy() + + rerender() + expect(container.querySelector('svg')).toBeTruthy() + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx new file mode 100644 index 0000000000..b078355431 --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ModalFoot from '../modal-foot' + +describe('ModalFoot', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render cancel and save actions', () => { + render() + + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + }) + + it('should trigger callbacks when action buttons are clicked', () => { + const onCancel = vi.fn() + const onConfirm = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx new file mode 100644 index 0000000000..31ff683d86 --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SelectVarType from '../select-var-type' + +describe('SelectVarType', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const clickAddTrigger = () => { + const label = screen.getByText('common.operation.add') + const trigger = label.closest('div.cursor-pointer') + expect(trigger).not.toBeNull() + fireEvent.click(trigger!) + } + + it('should open the type list from the add trigger', async () => { + render() + + clickAddTrigger() + + expect(await screen.findByText('appDebug.variableConfig.string')).toBeInTheDocument() + expect(screen.getByText('appDebug.variableConfig.apiBasedVar')).toBeInTheDocument() + }) + + it('should emit the selected type and close the list', async () => { + const onChange = vi.fn() + render() + + clickAddTrigger() + fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar')) + + expect(onChange).toHaveBeenCalledWith('api') + await waitFor(() => { + expect(screen.queryByText('appDebug.variableConfig.string')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx new file mode 100644 index 0000000000..a2ae4c705d --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VarItem from '../var-item' + +describe('VarItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render variable metadata and required badge', () => { + render( + , + ) + + expect(screen.getByTitle('customer_name · Customer Name')).toBeInTheDocument() + expect(screen.getByText('required')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + }) + + it('should trigger edit and remove callbacks', () => { + const onEdit = vi.fn() + const onRemove = vi.fn() + + const { container } = render( + , + ) + + const actionButtons = container.querySelectorAll('div.h-6.w-6') + fireEvent.click(actionButtons[0]) + fireEvent.click(screen.getByTestId('var-item-delete-btn')) + + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onRemove).toHaveBeenCalledTimes(1) + }) + + it('should highlight destructive state while hovering the delete action', () => { + const { container } = render( + , + ) + + const item = container.firstElementChild as HTMLElement + const deleteButton = screen.getByTestId('var-item-delete-btn') + + fireEvent.mouseOver(deleteButton) + expect(item.className).toContain('border-state-destructive-border') + + fireEvent.mouseLeave(deleteButton) + expect(item.className).not.toContain('border-state-destructive-border') + }) +}) diff --git a/web/app/components/app/configuration/config-var/helpers.ts b/web/app/components/app/configuration/config-var/helpers.ts new file mode 100644 index 0000000000..1297c7a5eb --- /dev/null +++ b/web/app/components/app/configuration/config-var/helpers.ts @@ -0,0 +1,82 @@ +import type { InputVar } from '@/app/components/workflow/types' +import type { ExternalDataTool } from '@/models/common' +import type { PromptVariable } from '@/models/debug' +import { InputVarType } from '@/app/components/workflow/types' +import { hasDuplicateStr } from '@/utils/var' + +export type ExternalDataToolParams = { + key: string + type: string + index: number + name: string + config?: PromptVariable['config'] + icon?: string + icon_background?: string +} + +export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL' + +export const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox']) + +export 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, +}) + +export 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.select) + delete nextItem.options + + return nextItem +} + +export 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 const buildPromptVariableFromExternalDataTool = ( + externalDataTool: ExternalDataTool, + required: boolean, +): PromptVariable => ({ + key: externalDataTool.variable as string, + name: externalDataTool.label as string, + enabled: externalDataTool.enabled, + type: externalDataTool.type as string, + config: externalDataTool.config, + required, + icon: externalDataTool.icon, + icon_background: externalDataTool.icon_background, +}) + +export const createPromptVariablesWithIds = (promptVariables: PromptVariable[]) => { + return promptVariables.map((item) => { + return { + id: item.key, + variable: { ...item }, + } + }) +} diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx index a48d3233f5..959e1a5bbb 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -1,16 +1,26 @@ import type { ReactNode } from 'react' import type { IConfigVarProps } from './index' +import type DebugConfigurationContext from '@/context/debug-configuration' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' import { vi } from 'vitest' import { toast } from '@/app/components/base/ui/toast' -import DebugConfigurationContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index' +const mockUseContext = vi.fn() + +vi.mock('use-context-selector', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useContext: (context: unknown) => mockUseContext(context), + } +}) + const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') const setShowExternalDataToolModal = vi.fn() @@ -97,11 +107,8 @@ const renderConfigVar = (props: Partial = {}, debugOverrides: P ...props, } - return render( - - - , - ) + mockUseContext.mockReturnValue(createDebugConfigValue(debugOverrides)) + return render() } describe('ConfigVar', () => { @@ -143,6 +150,19 @@ describe('ConfigVar', () => { expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar]) }) + + it('should hide editing affordances in readonly mode', () => { + renderConfigVar({ + promptVariables: [createPromptVariable({ key: 'readonly', name: 'Readonly' })], + readonly: true, + }) + + const item = screen.getByTitle('readonly · Readonly') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + expect(screen.queryByText('common.operation.add')).not.toBeInTheDocument() + expect(itemContainer!.className).toContain('cursor-not-allowed') + }) }) // Variable creation flows using the add menu. @@ -209,6 +229,85 @@ describe('ConfigVar', () => { expect(addedVariables[1].type).toBe('api') expect(onPromptVariablesChange).toHaveBeenLastCalledWith([existingVar]) }) + + it('should validate and save external data tool edits from the modal callback', async () => { + const onPromptVariablesChange = vi.fn() + const existingVar = createPromptVariable({ + config: { region: 'us' }, + key: 'api_var', + name: 'API Var', + type: 'api', + }) + + renderConfigVar({ + promptVariables: [existingVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('api_var · API Var') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + fireEvent.click(actionButtons[0]) + + const modalState = setShowExternalDataToolModal.mock.calls[0][0] + expect(modalState.onValidateBeforeSaveCallback?.({ + label: 'Updated API Var', + type: 'api', + variable: 'updated_api_var', + })).toBe(true) + + act(() => { + modalState.onSaveCallback?.({ + config: { region: 'eu' }, + enabled: false, + icon: 'updated-icon', + icon_background: '#fff', + label: 'Updated API Var', + type: 'api', + variable: 'updated_api_var', + }) + }) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + config: { region: 'eu' }, + enabled: false, + icon: 'updated-icon', + icon_background: '#fff', + key: 'updated_api_var', + name: 'Updated API Var', + required: false, + type: 'api', + }), + ]) + }) + + it('should reject duplicated external data tool keys before saving', async () => { + const onPromptVariablesChange = vi.fn() + const existingVar = createPromptVariable({ key: 'existing', name: 'Existing' }) + const apiVar = createPromptVariable({ key: 'api_var', name: 'API Var', type: 'api' }) + + renderConfigVar({ + promptVariables: [existingVar, apiVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('api_var · API Var') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + fireEvent.click(actionButtons[0]) + + const modalState = setShowExternalDataToolModal.mock.calls[0][0] + expect(modalState.onValidateBeforeSaveCallback?.({ + label: 'Duplicated API Var', + type: 'api', + variable: 'existing', + })).toBe(false) + expect(toastErrorSpy).toHaveBeenCalled() + expect(onPromptVariablesChange).not.toHaveBeenCalled() + }) }) // Editing flows for variables through the modal. diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 17f5e2efe5..5e4b4eda0e 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -1,84 +1,19 @@ 'use client' 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 { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' -import { useContext } from 'use-context-selector' import Confirm from '@/app/components/base/confirm' import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' -import { InputVarType } from '@/app/components/workflow/types' -import ConfigContext from '@/context/debug-configuration' -import { useEventEmitterContextContext } from '@/context/event-emitter' -import { useModalContext } from '@/context/modal-context' -import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' -import { getNewVar, hasDuplicateStr } from '@/utils/var' import Panel from '../base/feature-panel' import EditModal from './config-modal' import SelectVarType from './select-var-type' +import { useConfigVarState } from './use-config-var-state' import VarItem from './var-item' -export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL' - -type ExternalDataToolParams = { - key: string - type: string - index: number - name: string - 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.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 { ADD_EXTERNAL_DATA_TOOL } from './helpers' export type IConfigVarProps = { promptVariables: PromptVariable[] @@ -89,159 +24,27 @@ export type IConfigVarProps = { const ConfigVar: FC = ({ promptVariables, readonly, onPromptVariablesChange }) => { const { t } = useTranslation() const { - mode, - dataSets, - } = useContext(ConfigContext) - const { eventEmitter } = useEventEmitterContextContext() - - const hasVar = promptVariables.length > 0 - const [currIndex, setCurrIndex] = useState(-1) - const currItem = currIndex !== -1 ? promptVariables[currIndex] : null - const currItemToEdit = useMemo(() => { - if (!currItem) - return null - return toInputVar(currItem) - }, [currItem]) - const updatePromptVariableItem = useCallback((payload: InputVar) => { - const newPromptVariables = produce(promptVariables, (draft) => { - draft[currIndex] = buildPromptVariableFromInput(payload) - }) - const duplicateError = getDuplicateError(newPromptVariables) - if (duplicateError) { - toast.error(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 = useCallback(( - { key, type, index, name, config, icon, icon_background }: ExternalDataToolParams, - oldPromptVariables: PromptVariable[], - ) => { - setShowExternalDataToolModal({ - payload: { - type, - variable: key, - label: name, - config, - icon, - icon_background, - }, - onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { - if (!newExternalDataTool) - return - const newPromptVariables = oldPromptVariables.map((item, i) => { - if (i === index) { - return { - key: newExternalDataTool.variable as string, - name: newExternalDataTool.label as string, - enabled: newExternalDataTool.enabled, - type: newExternalDataTool.type as string, - config: newExternalDataTool.config, - required: item.required, - icon: newExternalDataTool.icon, - icon_background: newExternalDataTool.icon_background, - } - } - return item - }) - onPromptVariablesChange?.(newPromptVariables) - }, - onCancelCallback: () => { - if (!key) - onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) - }, - onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { - for (let i = 0; i < promptVariables.length; i++) { - if (promptVariables[i].key === newExternalDataTool.variable && i !== index) { - toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key })) - return false - } - } - - return true - }, - }) - }, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t]) - - const handleAddVar = useCallback((type: string) => { - const newVar = getNewVar('', type) - const newPromptVariables = [...promptVariables, newVar] - onPromptVariablesChange?.(newPromptVariables) - - if (type === 'api') { - handleOpenExternalDataToolModal({ - type, - key: newVar.key, - name: newVar.name, - 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 - onPromptVariablesChange?.([ - ...promptVariables, - { - key: payload.variable as string, - name: payload.label as string, - enabled: payload.enabled, - type: payload.type as string, - config: payload.config, - required: true, - icon: payload.icon, - icon_background: payload.icon_background, - }, - ]) - } + canDrag, + currItemToEdit, + handleAddVar, + handleConfig, + handleDeleteContextVarConfirm, + handleEditConfirm, + handleRemoveVar, + handleSort, + hasVar, + hideDeleteContextVarModal, + hideEditModal, + isShowDeleteContextVarModal, + isShowEditModal, + promptVariablesWithIds, + removeIndex, + } = useConfigVarState({ + promptVariables, + readonly, + onPromptVariablesChange, }) - const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false) - const [removeIndex, setRemoveIndex] = useState(null) - const didRemoveVar = useCallback((index: number) => { - onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index)) - }, [onPromptVariablesChange, promptVariables]) - - const handleRemoveVar = useCallback((index: number) => { - const removeVar = promptVariables[index] - - if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) { - showDeleteContextVarModal() - setRemoveIndex(index) - return - } - didRemoveVar(index) - }, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal]) - - const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) - - const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { - // setCurrKey(key) - setCurrIndex(index) - 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 { - id: item.key, - variable: { ...item }, - } - }), [promptVariables]) - - const canDrag = !readonly && promptVariables.length > 1 - return ( = ({ promptVariables, readonly, onPromptVar { onPromptVariablesChange?.(list.map(item => item.variable)) }} + setList={handleSort} handle=".handle" ghostClass="opacity-50" animation={150} @@ -303,12 +106,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar payload={currItemToEdit!} isShow={isShowEditModal} onClose={hideEditModal} - onConfirm={(item) => { - const isValid = updatePromptVariableItem(item) - if (!isValid) - return - hideEditModal() - }} + onConfirm={handleEditConfirm} varKeys={promptVariables.map(v => v.key)} /> )} @@ -318,10 +116,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar isShow={isShowDeleteContextVarModal} title={t('feature.dataSet.queryVariable.deleteContextVarTitle', { ns: 'appDebug', varName: promptVariables[removeIndex as number]?.name })} content={t('feature.dataSet.queryVariable.deleteContextVarTip', { ns: 'appDebug' })} - onConfirm={() => { - didRemoveVar(removeIndex as number) - hideDeleteContextVarModal() - }} + onConfirm={handleDeleteContextVarConfirm} onCancel={hideDeleteContextVarModal} /> )} diff --git a/web/app/components/app/configuration/config-var/use-config-var-state.ts b/web/app/components/app/configuration/config-var/use-config-var-state.ts new file mode 100644 index 0000000000..87497ae548 --- /dev/null +++ b/web/app/components/app/configuration/config-var/use-config-var-state.ts @@ -0,0 +1,215 @@ +import type { ExternalDataToolParams } from './helpers' +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 { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { toast } from '@/app/components/base/ui/toast' +import ConfigContext from '@/context/debug-configuration' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { useModalContext } from '@/context/modal-context' +import { AppModeEnum } from '@/types/app' +import { getNewVar } from '@/utils/var' +import { + ADD_EXTERNAL_DATA_TOOL, + BASIC_INPUT_TYPES, + buildPromptVariableFromExternalDataTool, + buildPromptVariableFromInput, + createPromptVariablesWithIds, + + getDuplicateError, + toInputVar, +} from './helpers' + +type ExternalDataToolEvent = { + payload: ExternalDataTool + type: string +} + +type UseConfigVarStateParams = { + promptVariables: PromptVariable[] + readonly?: boolean + onPromptVariablesChange?: (promptVariables: PromptVariable[]) => void +} + +export const useConfigVarState = ({ + promptVariables, + readonly, + onPromptVariablesChange, +}: UseConfigVarStateParams) => { + const { t } = useTranslation() + const { + mode, + dataSets, + } = useContext(ConfigContext) + const { eventEmitter } = useEventEmitterContextContext() + const { setShowExternalDataToolModal } = useModalContext() + + const hasVar = promptVariables.length > 0 + const [currIndex, setCurrIndex] = useState(-1) + const [removeIndex, setRemoveIndex] = useState(null) + const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false) + const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false) + + const currItem = currIndex !== -1 ? promptVariables[currIndex] : null + const currItemToEdit = useMemo(() => { + if (!currItem) + return null + + return toInputVar(currItem) + }, [currItem]) + + const openExternalDataToolModal = useCallback(( + { key, type, index, name, config, icon, icon_background }: ExternalDataToolParams, + oldPromptVariables: PromptVariable[], + ) => { + setShowExternalDataToolModal({ + payload: { + type, + variable: key, + label: name, + config, + icon, + icon_background, + }, + onSaveCallback: (newExternalDataTool?: ExternalDataTool) => { + if (!newExternalDataTool) + return + + const newPromptVariables = oldPromptVariables.map((item, itemIndex) => { + if (itemIndex === index) + return buildPromptVariableFromExternalDataTool(newExternalDataTool, item.required ?? false) + + return item + }) + + onPromptVariablesChange?.(newPromptVariables) + }, + onCancelCallback: () => { + if (!key) + onPromptVariablesChange?.(promptVariables.filter((_, itemIndex) => itemIndex !== index)) + }, + onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => { + for (let i = 0; i < promptVariables.length; i++) { + if (promptVariables[i].key === newExternalDataTool.variable && i !== index) { + toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key })) + return false + } + } + + return true + }, + }) + }, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t]) + + const updatePromptVariableItem = useCallback((payload: InputVar) => { + const newPromptVariables = produce(promptVariables, (draft) => { + draft[currIndex] = buildPromptVariableFromInput(payload) + }) + const duplicateError = getDuplicateError(newPromptVariables) + if (duplicateError) { + toast.error(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 handleAddVar = useCallback((type: string) => { + const newVar = getNewVar('', type) + const newPromptVariables = [...promptVariables, newVar] + onPromptVariablesChange?.(newPromptVariables) + + if (type === 'api') { + openExternalDataToolModal({ + type, + key: newVar.key, + name: newVar.name, + index: promptVariables.length, + }, newPromptVariables) + } + }, [onPromptVariablesChange, openExternalDataToolModal, promptVariables]) + + eventEmitter?.useSubscription((event) => { + if (typeof event === 'string' || event.type !== ADD_EXTERNAL_DATA_TOOL || !event.payload) + return + + onPromptVariablesChange?.([ + ...promptVariables, + buildPromptVariableFromExternalDataTool(event.payload as ExternalDataToolEvent['payload'], true), + ]) + }) + + const didRemoveVar = useCallback((index: number) => { + onPromptVariablesChange?.(promptVariables.filter((_, itemIndex) => itemIndex !== index)) + }, [onPromptVariablesChange, promptVariables]) + + const handleRemoveVar = useCallback((index: number) => { + const removeVar = promptVariables[index] + + if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) { + showDeleteContextVarModal() + setRemoveIndex(index) + return + } + + didRemoveVar(index) + }, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal]) + + const handleConfig = useCallback((params: ExternalDataToolParams) => { + setCurrIndex(params.index) + if (!BASIC_INPUT_TYPES.has(params.type)) { + openExternalDataToolModal(params, promptVariables) + return + } + + showEditModal() + }, [openExternalDataToolModal, promptVariables, showEditModal]) + + const handleSort = useCallback((list: ReturnType) => { + onPromptVariablesChange?.(list.map(item => item.variable)) + }, [onPromptVariablesChange]) + + const handleEditConfirm = useCallback((item: InputVar) => { + const isValid = updatePromptVariableItem(item) + if (!isValid) + return false + + hideEditModal() + return true + }, [hideEditModal, updatePromptVariableItem]) + + const handleDeleteContextVarConfirm = useCallback(() => { + didRemoveVar(removeIndex as number) + hideDeleteContextVarModal() + }, [didRemoveVar, hideDeleteContextVarModal, removeIndex]) + + const promptVariablesWithIds = useMemo(() => createPromptVariablesWithIds(promptVariables), [promptVariables]) + const canDrag = !readonly && promptVariables.length > 1 + + return { + canDrag, + currItemToEdit, + handleAddVar, + handleConfig, + handleDeleteContextVarConfirm, + handleRemoveVar, + handleSort, + handleEditConfirm, + hasVar, + hideDeleteContextVarModal, + hideEditModal, + isShowDeleteContextVarModal, + isShowEditModal, + promptVariablesWithIds, + removeIndex, + } +} diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx new file mode 100644 index 0000000000..d0e776e106 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx @@ -0,0 +1,90 @@ +import type { Node, ValueSelector, Var } from '@/app/components/workflow/types' +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { VarType } from '@/app/components/workflow/types' +import InstructionEditorInWorkflow from '../instruction-editor-in-workflow' +import { GeneratorType } from '../types' + +const mockPromptEditor = vi.fn() +const mockUseAvailableVarList = vi.fn() +const mockGetState = vi.fn() +const mockUseWorkflowVariableType = vi.fn() + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: Record) => { + mockPromptEditor(props) + return
{String(props.value ?? '')}
+ }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + default: (...args: unknown[]) => mockUseAvailableVarList(...args), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: mockGetState, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowVariableType: () => mockUseWorkflowVariableType(), +})) + +const availableNodes: Node[] = [{ + data: { + title: 'Node A', + type: 'llm', + }, + height: 80, + id: 'node-a', + position: { x: 0, y: 0 }, + width: 160, +}] as unknown as Node[] + +describe('InstructionEditorInWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetState.mockReturnValue({ + nodesWithInspectVars: [{ nodeId: 'node-a' }, { nodeId: 'node-b' }], + }) + mockUseWorkflowVariableType.mockReturnValue(() => 'string') + mockUseAvailableVarList.mockReturnValue({ + availableNodes, + availableVars: [{ value_selector: ['node-a', 'text'] }], + }) + }) + + it('should wire workflow variables into the shared instruction editor', () => { + render( + , + ) + + const filterVar = mockUseAvailableVarList.mock.calls[0][1].filterVar as (payload: Var, selector: ValueSelector) => boolean + expect(filterVar({ type: VarType.string } as Var, ['node-a'])).toBe(true) + expect(filterVar({ type: VarType.file } as Var, ['node-a'])).toBe(false) + expect(filterVar({ type: VarType.string } as Var, ['node-c'])).toBe(false) + + expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ + currentBlock: { + generatorType: GeneratorType.prompt, + show: true, + }, + lastRunBlock: { + show: true, + }, + value: 'Workflow prompt', + workflowVariableBlock: expect.objectContaining({ + show: true, + variables: [{ value_selector: ['node-a', 'text'] }], + }), + })) + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx new file mode 100644 index 0000000000..d8a95d679c --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx @@ -0,0 +1,120 @@ +import type { Node, NodeOutPutVar } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block' +import { BlockEnum } from '@/app/components/workflow/types' +import InstructionEditor from '../instruction-editor' +import { GeneratorType } from '../types' + +const mockPromptEditor = vi.fn() +const mockEmit = vi.fn() + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: Record) => { + mockPromptEditor(props) + return
{String(props.value ?? '')}
+ }, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: mockEmit, + }, + }), +})) + +const availableVars: NodeOutPutVar[] = [{ value_selector: ['sys', 'query'] }] as unknown as NodeOutPutVar[] +const availableNodes: Node[] = [{ + data: { + title: 'Start Node', + type: BlockEnum.Start, + }, + height: 100, + id: 'start-node', + position: { x: 10, y: 20 }, + width: 120, +}] as unknown as Node[] + +describe('InstructionEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render prompt placeholder blocks and insert-context trigger', () => { + render( + , + ) + + expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ + currentBlock: { + generatorType: GeneratorType.prompt, + show: false, + }, + errorMessageBlock: { + show: false, + }, + lastRunBlock: { + show: false, + }, + value: 'Prompt value', + workflowVariableBlock: expect.objectContaining({ + show: true, + variables: availableVars, + workflowNodesMap: expect.objectContaining({ + 'start-node': expect.objectContaining({ + title: 'Start Node', + type: BlockEnum.Start, + }), + 'sys': expect.objectContaining({ + title: 'workflow.blocks.start', + type: BlockEnum.Start, + }), + }), + }), + })) + + fireEvent.click(screen.getByText('appDebug.generate.insertContext')) + expect(mockEmit).toHaveBeenCalledWith({ + instanceId: 'editor-1', + type: PROMPT_EDITOR_INSERT_QUICKLY, + }) + }) + + it('should enable code-specific blocks for code generators', () => { + render( + , + ) + + expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ + currentBlock: { + generatorType: GeneratorType.code, + show: true, + }, + errorMessageBlock: { + show: true, + }, + lastRunBlock: { + show: true, + }, + })) + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx new file mode 100644 index 0000000000..c86fabfcbd --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PromptToast from '../prompt-toast' + +describe('PromptToast', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the optimization note and markdown message', () => { + render() + + expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) + + it('should toggle folded state from the arrow trigger', () => { + const { container } = render() + const toggle = container.querySelector('.size-4.cursor-pointer') + expect(toggle).not.toBeNull() + + fireEvent.click(toggle!) + expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument() + + fireEvent.click(toggle!) + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx new file mode 100644 index 0000000000..1c0fb00039 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ResPlaceholder from '../res-placeholder' + +describe('ResPlaceholder', () => { + it('should render the empty-state copy', () => { + render() + + expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx new file mode 100644 index 0000000000..17163f9da8 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx @@ -0,0 +1,144 @@ +import type { GenRes } from '@/service/debug' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from '@/app/components/base/ui/toast' +import { BlockEnum } from '@/app/components/workflow/types' +import Result from '../result' +import { GeneratorType } from '../types' + +const mockCopy = vi.fn() +const mockPromptEditor = vi.fn() +const mockCodeEditor = vi.fn() +const mockUseAvailableVarList = vi.fn() + +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: { + value: string + workflowVariableBlock: Record + }) => { + mockPromptEditor(props) + return
{props.value}
+ }, +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor', () => ({ + default: (props: { value?: string }) => { + mockCodeEditor(props) + return
{props.value}
+ }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({ + default: (...args: unknown[]) => mockUseAvailableVarList(...args), +})) + +const createCurrent = (overrides: Partial = {}): GenRes => ({ + message: 'Optimization note', + modified: 'Generated result', + ...overrides, +}) + +describe('Result', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(toast, 'success').mockImplementation(vi.fn()) + mockUseAvailableVarList.mockReturnValue({ + availableNodes: [{ + data: { + title: 'Start Node', + type: BlockEnum.Start, + }, + height: 100, + id: 'start-node', + position: { x: 10, y: 20 }, + width: 120, + }], + availableVars: [{ value_selector: ['sys', 'query'] }], + }) + }) + + it('should render prompt results in basic mode and support copy/apply actions', () => { + const onApply = vi.fn() + render( + , + ) + + fireEvent.click(screen.getAllByRole('button')[0]) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.generate.apply' })) + + expect(mockCopy).toHaveBeenCalledWith('Generated result') + expect(toast.success).toHaveBeenCalledWith('common.actionMsg.copySuccessfully') + expect(onApply).toHaveBeenCalledTimes(1) + expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({ + value: 'Generated result', + workflowVariableBlock: { + show: false, + }, + })) + expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) + + it('should render workflow prompt results with workflow variable metadata', () => { + render( + , + ) + + const promptEditorProps = mockPromptEditor.mock.lastCall?.[0] + expect(promptEditorProps).toEqual(expect.objectContaining({ + value: 'v2', + workflowVariableBlock: expect.objectContaining({ + show: true, + variables: [{ value_selector: ['sys', 'query'] }], + workflowNodesMap: expect.objectContaining({ + 'start-node': expect.objectContaining({ + title: 'Start Node', + type: BlockEnum.Start, + }), + 'sys': expect.objectContaining({ + title: 'workflow.blocks.start', + type: BlockEnum.Start, + }), + }), + }), + })) + }) + + it('should render code results through the code editor branch', () => { + render( + , + ) + + expect(screen.getByTestId('code-editor')).toHaveTextContent('{"name":"demo"}') + expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({ + value: '{"name":"demo"}', + })) + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts new file mode 100644 index 0000000000..f3300bcb8a --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import useGenData from '../use-gen-data' + +describe('useGenData', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('should initialize empty version state for a new storage key', () => { + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + expect(result.current.versions).toEqual([]) + expect(result.current.currentVersionIndex).toBe(0) + expect(result.current.current).toBeUndefined() + }) + + it('should append versions and move the current index to the latest version', () => { + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + act(() => { + result.current.addVersion({ modified: 'first' }) + }) + expect(result.current.versions).toEqual([{ modified: 'first' }]) + expect(result.current.currentVersionIndex).toBe(0) + expect(result.current.current).toEqual({ modified: 'first' }) + + act(() => { + result.current.addVersion({ message: 'hint', modified: 'second' }) + }) + expect(result.current.versions).toEqual([ + { modified: 'first' }, + { message: 'hint', modified: 'second' }, + ]) + expect(result.current.currentVersionIndex).toBe(1) + expect(result.current.current).toEqual({ message: 'hint', modified: 'second' }) + }) + + it('should persist and restore versions by storage key', () => { + const { result, unmount } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + act(() => { + result.current.addVersion({ modified: 'first' }) + }) + act(() => { + result.current.addVersion({ modified: 'second' }) + }) + act(() => { + result.current.setCurrentVersionIndex(0) + }) + + unmount() + + const { result: nextResult } = renderHook(() => useGenData({ storageKey: 'prompt' })) + expect(nextResult.current.versions).toEqual([ + { modified: 'first' }, + { modified: 'second' }, + ]) + expect(nextResult.current.currentVersionIndex).toBe(0) + expect(nextResult.current.current).toEqual({ modified: 'first' }) + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx new file mode 100644 index 0000000000..b1c91e2dd9 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VersionSelector from '../version-selector' + +describe('VersionSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const clickTrigger = () => { + fireEvent.click(screen.getByText('appDebug.generate.version 2 · appDebug.generate.latest')) + } + + it('should render the current version label and keep single-version selectors closed', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByText('appDebug.generate.version 1 · appDebug.generate.latest')) + + expect(screen.queryByText('appDebug.generate.versions')).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('should open the selector and emit the chosen version', async () => { + const onChange = vi.fn() + render() + + clickTrigger() + fireEvent.click(await screen.findByTitle('appDebug.generate.version 1')) + + expect(onChange).toHaveBeenCalledWith(0) + await waitFor(() => { + expect(screen.queryByText('appDebug.generate.versions')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/reasoning-config-form.spec.tsx new file mode 100644 index 0000000000..8853464861 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/reasoning-config-form.spec.tsx @@ -0,0 +1,534 @@ +import type { ReactNode } from 'react' +import type { Node } from 'reactflow' +import { fireEvent, render, screen } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' +import ReasoningConfigForm from '../reasoning-config-form' + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ + children, + onValueChange, + value, + }: { + children: ReactNode + onValueChange: (value: string) => void + value?: string + }) => ( +
+ {children} + +
+ ), + SelectTrigger: ({ children, className }: { children: ReactNode, className?: string }) => ( +
{children}
+ ), + SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder ?? 'Select'}, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: ReactNode, value: string }) =>
{children}
, +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ + children, + className, + onClick, + }: { + children: ReactNode + className?: string + onClick?: () => void + }) => ( + + ), + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ + onSelect, + }: { + onSelect: (value: { app_id: string, inputs: Record, files?: unknown[] }) => void + }) => , +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ + setModel, + }: { + setModel: (value: Record) => void + }) => , +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ + onChange, + }: { + onChange: (value: unknown) => void + }) => , +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-boolean', () => ({ + default: ({ + value, + onChange, + }: { + value?: boolean + onChange: (value: boolean) => void + }) => , +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch', () => ({ + default: ({ + value, + onChange, + }: { + value: string + onChange: (value: VarKindType) => void + }) => ( +
+ {value} + +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ + onChange, + filterVar, + valueTypePlaceHolder, + }: { + onChange: (value: string[]) => void + filterVar?: (value: { type: string }) => boolean + valueTypePlaceHolder?: string + }) => { + const matchesFilter = filterVar?.({ type: valueTypePlaceHolder ?? 'unknown' }) ?? false + return ( +
+ {`matches-filter:${String(matchesFilter)}`} + +
+ ) + }, +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ + value, + onChange, + }: { + value: string + onChange: (value: string) => void + }) => ( +
+ {value} + +
+ ), +})) + +vi.mock('../schema-modal', () => ({ + default: ({ + isShow, + rootName, + onClose, + }: { + isShow: boolean + rootName: string + onClose: () => void + }) => ( + isShow + ? ( +
+ {rootName} + +
+ ) + : null + ), +})) + +const availableNodes: Node[] = [] + +const createSchema = (overrides: Record) => ({ + default: '', + variable: 'field', + label: { en_US: 'Field' }, + required: false, + tooltip: { en_US: 'Helpful tooltip' }, + type: FormTypeEnum.textInput, + scope: 'all', + url: undefined, + input_schema: undefined, + placeholder: { en_US: 'Enter value' }, + options: [], + ...overrides, +}) + +const createValue = (value: Record) => value + +describe('ReasoningConfigForm', () => { + it('should render mixed text input and support auto mode toggling when variable reference is allowed', () => { + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByTestId('mixed-variable-text-input')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Update Mixed Text' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + prompt: { + auto: 0, + value: { + type: VarKindType.mixed, + value: 'mixed-updated', + }, + }, + })) + + fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.auto')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + prompt: { + auto: 1, + value: null, + }, + })) + }) + + it('should use plain input when variable reference is disabled', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Enter prompt'), { target: { value: 'after' } }) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + plain_text: { + auto: 0, + value: { + type: VarKindType.mixed, + value: 'after', + }, + }, + })) + }) + + it('should update typed fields, selectors, and variable references across supported schema types', () => { + const onChange = vi.fn() + const enabledValue = { auto: 0, value: { type: VarKindType.constant, value: false } } + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Enter number'), { target: { value: '3' } }) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + count: { + auto: 0, + value: { + type: VarKindType.constant, + value: '3', + }, + }, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Switch To Variable' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + count: { + auto: 0, + value: { + type: VarKindType.variable, + value: '', + }, + }, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Toggle Boolean' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + enabled: { + auto: 0, + value: { + type: VarKindType.constant, + value: true, + }, + }, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Choose Select Option' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + mode: { + auto: 0, + value: { + type: VarKindType.constant, + value: 'selected-option', + }, + }, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Select App' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + assistant: expect.objectContaining({ + value: { + app_id: 'app-1', + inputs: { query: 'hello' }, + }, + }), + })) + + fireEvent.click(screen.getByRole('button', { name: 'Select Model' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + model: expect.objectContaining({ + value: { + provider: 'openai', + model: 'gpt-4o-mini', + }, + }), + })) + + fireEvent.click(screen.getByRole('button', { name: 'Select Variable' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + attachments: { + auto: 0, + value: { + type: VarKindType.variable, + value: ['node-1', 'var-1'], + }, + }, + })) + + expect(screen.getAllByText('plugin.detailPanel.toolSelector.auto').length).toBeGreaterThan(0) + }) + + it('should toggle file auto mode through the switch control', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + file_input: { + auto: 1, + value: null, + }, + })) + }) + + it('should compute variable filters for number, string, boolean, object, array, and files selectors', () => { + render( + , + ) + + expect(screen.getByTestId(`var-picker-${VarType.string}`)).toHaveTextContent('matches-filter:true') + expect(screen.getByTestId(`var-picker-${VarType.number}`)).toHaveTextContent('matches-filter:true') + expect(screen.getByTestId(`var-picker-${VarType.boolean}`)).toHaveTextContent('matches-filter:true') + expect(screen.getByTestId(`var-picker-${VarType.object}`)).toHaveTextContent('matches-filter:true') + expect(screen.getByTestId(`var-picker-${VarType.arrayObject}`)).toHaveTextContent('matches-filter:true') + expect(screen.getByTestId(`var-picker-${VarType.arrayFile}`)).toHaveTextContent('matches-filter:true') + }) + + it('should render json editor, schema modal, and help link for structured schemas', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Update JSON' })) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + payload: { + auto: 0, + value: { + type: VarKindType.constant, + value: { from: 'editor' }, + }, + }, + })) + + fireEvent.click(screen.getByTestId('schema-trigger')) + expect(screen.getByTestId('schema-modal')).toHaveTextContent('Payload') + + fireEvent.click(screen.getByRole('button', { name: 'Close Schema' })) + expect(screen.queryByTestId('schema-modal')).not.toBeInTheDocument() + + expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/docs') + }) + + it('should hide auto toggle for model selector schemas', () => { + render( + , + ) + + expect(screen.queryByText('plugin.detailPanel.toolSelector.auto')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/schema-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/schema-modal.spec.tsx new file mode 100644 index 0000000000..4c097da6ac --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/schema-modal.spec.tsx @@ -0,0 +1,76 @@ +import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' +import { fireEvent, render, screen } from '@testing-library/react' +import SchemaModal from '../schema-modal' + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({ + default: ({ + rootName, + readOnly, + }: { + rootName: string + readOnly?: boolean + }) => ( +
+ {rootName} + {readOnly ? 'readonly' : 'editable'} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({ + MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const schema = { + type: 'object', + properties: {}, +} as unknown as SchemaRoot + +describe('SchemaModal', () => { + it('should not render dialog content when hidden', () => { + render( + , + ) + + expect(screen.queryByText('workflow.nodes.agent.parameterSchema')).not.toBeInTheDocument() + expect(screen.queryByTestId('visual-editor')).not.toBeInTheDocument() + }) + + it('should render schema title and visual editor when shown', () => { + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument() + expect(screen.getByTestId('visual-editor')).toHaveTextContent('Tool Schema') + expect(screen.getByTestId('visual-editor')).toHaveTextContent('readonly') + }) + + it('should call onClose when the close button is clicked', () => { + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-authorization-section.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-authorization-section.spec.tsx new file mode 100644 index 0000000000..861de48a3e --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-authorization-section.spec.tsx @@ -0,0 +1,86 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { CollectionType } from '@/app/components/tools/types' +import ToolAuthorizationSection from '../tool-authorization-section' + +const mockPluginAuthInAgent = vi.fn(({ + credentialId, + onAuthorizationItemClick, +}: { + credentialId?: string + onAuthorizationItemClick?: (id: string) => void +}) => ( +
+ {credentialId ?? 'no-credential'} + +
+)) + +vi.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { tool: 'tool' }, + PluginAuthInAgent: (props: { + credentialId?: string + onAuthorizationItemClick?: (id: string) => void + }) => mockPluginAuthInAgent(props), +})) + +const createProvider = (overrides: Partial = {}): ToolWithProvider => ({ + name: 'builtin-provider', + type: CollectionType.builtIn, + allow_delete: true, + is_team_authorization: true, + ...overrides, +} as ToolWithProvider) + +describe('sections/tool-authorization-section', () => { + it('should render nothing when provider is missing', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render nothing for non built-in providers or providers without delete permission', () => { + const { rerender, container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render divider and auth component for supported providers', () => { + render( + , + ) + + expect(screen.getByTestId('divider')).toBeInTheDocument() + expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('credential-123') + }) + + it('should hide divider when noDivider is true and forward authorization clicks', () => { + const onAuthorizationItemClick = vi.fn() + + render( + , + ) + + expect(screen.queryByTestId('divider')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Select Credential' })) + + expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-1') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-settings-section.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-settings-section.spec.tsx new file mode 100644 index 0000000000..8d57906fdd --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/sections/__tests__/tool-settings-section.spec.tsx @@ -0,0 +1,186 @@ +import type { Node } from 'reactflow' +import type { Tool } from '@/app/components/tools/types' +import type { ToolValue } from '@/app/components/workflow/block-selector/types' +import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import ToolSettingsSection from '../tool-settings-section' + +vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ + getPlainValue: vi.fn((value: Record) => value), + getStructureValue: vi.fn(() => ({ structured: 'settings' })), + toolParametersToFormSchemas: vi.fn((schemas: unknown[]) => schemas), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({ + default: ({ + onChange, + schema, + }: { + onChange: (value: Record) => void + schema: unknown[] + }) => ( +
+ {`schema-count:${schema.length}`} + +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form', () => ({ + default: ({ + onChange, + nodeId, + schemas, + }: { + onChange: (value: Record) => void + nodeId: string + schemas: unknown[] + }) => ( +
+ {`node:${nodeId}`} + {`schema-count:${schemas.length}`} + +
+ ), +})) + +const createProvider = (overrides: Partial = {}): ToolWithProvider => ({ + name: 'provider', + is_team_authorization: true, + ...overrides, +} as ToolWithProvider) + +const createTool = (parameters: Array<{ form: string, name: string }>): Tool => ({ + parameters, +} as unknown as Tool) + +const createValue = (overrides: Partial = {}): ToolValue => ({ + provider_name: 'provider', + tool_name: 'tool', + settings: { + setting1: { + value: 'initial', + }, + }, + parameters: { + reasoning: { + auto: 0, + value: { + temperature: 0.2, + }, + }, + }, + ...overrides, +} as ToolValue) + +const nodeOutputVars: NodeOutPutVar[] = [] +const availableNodes: Node[] = [] + +describe('sections/tool-settings-section', () => { + it('should render nothing when provider is not team authorized', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render nothing when tool has no settings or params', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render user settings only and save structured settings', () => { + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('plugin.detailPanel.toolSelector.settings')).toBeInTheDocument() + expect(screen.getByTestId('tool-form')).toBeInTheDocument() + expect(screen.queryByTestId('reasoning-config-form')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Change Settings' })) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + settings: { structured: 'settings' }, + })) + }) + + it('should render reasoning config only and save parameters', () => { + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByText('plugin.detailPanel.toolSelector.params')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip1')).toBeInTheDocument() + expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('node:node-1') + expect(screen.getByTestId('tool-form')).toHaveTextContent('schema-count:0') + + fireEvent.click(screen.getByRole('button', { name: 'Change Params' })) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + parameters: { + reasoning: { + auto: 0, + value: { + temperature: 0.7, + }, + }, + }, + })) + }) + + it('should render tab slider and switch from settings to params when both forms exist', () => { + render( + , + ) + + expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByTestId('tool-form')).toBeInTheDocument() + expect(screen.queryByText('plugin.detailPanel.toolSelector.paramsTip1')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('tab-slider-item-params')) + + expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip1')).toBeInTheDocument() + expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip2')).toBeInTheDocument() + expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('schema-count:1') + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 19ce12b328..4446bd8fbd 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -29,6 +29,36 @@ import { dayjs.extend(utc) dayjs.extend(timezone) +vi.mock('react-i18next', async () => { + const React = await vi.importActual('react') + return { + useTranslation: (defaultNs?: string) => ({ + t: (key: string, options?: Record) => { + const ns = (options?.ns as string | undefined) ?? defaultNs + const fullKey = ns ? `${ns}.${key}` : key + const params = { ...options } + delete params.ns + const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : '' + return `${fullKey}${suffix}` + }, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), + Trans: ({ i18nKey, components }: { + i18nKey: string + components?: Record + }) => { + const timezoneComponent = components?.setTimezone + if (timezoneComponent && React.isValidElement(timezoneComponent)) + return React.createElement(timezoneComponent.type, timezoneComponent.props, i18nKey) + + return React.createElement('span', { 'data-i18n-key': i18nKey }, i18nKey) + }, + } +}) + // Mock app context const mockTimezone = 'America/New_York' vi.mock('@/context/app-context', () => ({ @@ -93,16 +123,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ }, })) +let latestFilteredMinutes: string[] = [] + // Mock TimePicker component - simplified stateless mock vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ - default: ({ value, onChange, onClear, renderTrigger }: { + default: ({ value, onChange, onClear, renderTrigger, minuteFilter }: { value: { format: (f: string) => string } onChange: (v: unknown) => void onClear: () => void + minuteFilter?: (minutes: string[]) => string[] title?: string renderTrigger: (params: { inputElem: React.ReactNode, onClick: () => void, isOpen: boolean }) => React.ReactNode }) => { const inputElem = {value.format('HH:mm')} + latestFilteredMinutes = minuteFilter?.(['00', '07', '15', '30', '45']) ?? [] return (
@@ -112,6 +146,7 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ isOpen: false, })}
+
{latestFilteredMinutes.join(',')}