From de62fd15bd36b8972a995c235300b32c6ae9db1e Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 27 Mar 2026 14:22:33 +0800 Subject: [PATCH] refactor(web): improve app component testability and raise coverage - refactor config-modal, create-from-dsl-modal, and sub-graph for lower complexity - add missing tests for config-modal-body, sandbox-provider-page, and install-bundle flows - raise targeted worktree source coverage to 90%+ for lines and statements --- .../__tests__/config-modal-body.spec.tsx | 342 +++++++ .../config-modal/__tests__/field.spec.tsx | 33 + .../config-modal/__tests__/helpers.spec.ts | 97 ++ .../config-modal/__tests__/index.spec.tsx | 136 +++ .../__tests__/type-select.spec.tsx | 65 ++ .../__tests__/use-config-modal-state.spec.tsx | 376 ++++++++ .../config-modal/config-modal-body.tsx | 297 ++++++ .../config-var/config-modal/helpers.ts | 59 ++ .../config-var/config-modal/index.tsx | 477 +--------- .../config-modal/use-config-modal-state.ts | 299 ++++++ .../use-advanced-prompt-config.spec.ts | 233 +++++ .../__tests__/dsl-confirm-modal.spec.tsx | 171 ++++ .../__tests__/index.spec.tsx | 141 +++ .../__tests__/uploader.spec.tsx | 98 ++ .../use-create-from-dsl-modal.spec.tsx | 477 ++++++++++ .../app/create-from-dsl-modal/index.tsx | 256 +----- .../use-create-from-dsl-modal.ts | 269 ++++++ .../__tests__/config-modal.spec.tsx | 186 ++++ .../__tests__/index.spec.tsx | 164 ++++ .../__tests__/provider-card.spec.tsx | 82 ++ .../__tests__/provider-icon.spec.tsx | 54 ++ .../__tests__/switch-modal.spec.tsx | 86 ++ .../item/__tests__/github-item.spec.tsx | 153 ++++ .../item/__tests__/items.spec.tsx | 336 +++++++ .../item/__tests__/loaded-item.spec.tsx | 115 +++ .../item/__tests__/marketplace-item.spec.tsx | 82 ++ .../item/__tests__/package-item.spec.tsx | 106 +++ .../steps/__tests__/installed.spec.tsx | 117 +++ .../__tests__/use-install-multi-state.spec.ts | 860 +++++------------- .../sub-graph/__tests__/index.spec.tsx | 143 +++ .../sub-graph/__tests__/utils.spec.ts | 111 +++ .../__tests__/use-sub-graph-nodes.spec.ts | 34 + web/app/components/sub-graph/index.tsx | 202 +--- .../sub-graph/store/__tests__/index.spec.ts | 28 + web/app/components/sub-graph/utils.ts | 169 ++++ web/service/debug.ts | 2 +- 36 files changed, 5364 insertions(+), 1492 deletions(-) create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/config-modal-body.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/helpers.spec.ts create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/use-config-modal-state.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/config-modal-body.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/helpers.ts create mode 100644 web/app/components/app/configuration/config-var/config-modal/use-config-modal-state.ts create mode 100644 web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.ts create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/use-create-from-dsl-modal.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/use-create-from-dsl-modal.ts create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/__tests__/config-modal.spec.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/__tests__/index.spec.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/__tests__/provider-card.spec.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/__tests__/provider-icon.spec.tsx create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/__tests__/switch-modal.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/items.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx create mode 100644 web/app/components/sub-graph/__tests__/index.spec.tsx create mode 100644 web/app/components/sub-graph/__tests__/utils.spec.ts create mode 100644 web/app/components/sub-graph/hooks/__tests__/use-sub-graph-nodes.spec.ts create mode 100644 web/app/components/sub-graph/store/__tests__/index.spec.ts create mode 100644 web/app/components/sub-graph/utils.ts diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/config-modal-body.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/config-modal-body.spec.tsx new file mode 100644 index 0000000000..68e2072fd4 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/config-modal-body.spec.tsx @@ -0,0 +1,342 @@ +import type { ConfigModalState } from '../use-config-modal-state' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { InputVar } from '@/app/components/workflow/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import ConfigModalBody from '../config-modal-body' + +type PayloadChangeHandler = ConfigModalState['handlePayloadChange'] +type PayloadValueHandler = ReturnType + +let selectOnValueChange: ((value: string | null) => void) | undefined + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ + children, + onValueChange, + }: { + children: React.ReactNode + onValueChange?: (value: string | null) => void + }) => { + selectOnValueChange = onValueChange + return
{children}
+ }, + SelectTrigger: ({ + children, + className, + 'aria-label': ariaLabel, + }: { + 'children': React.ReactNode + 'className'?: string + 'aria-label'?: string + }) => ( + + ), + SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder}, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ + children, + value, + }: { + children: React.ReactNode + value: string + }) => ( +
selectOnValueChange?.(value)}> + {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => ( +
+
{value}
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ + default: ({ + isMultiple, + onChange, + payload, + }: { + isMultiple: boolean + onChange: (payload: InputVar) => void + payload: InputVar + }) => ( +
+
{String(isMultiple)}
+ +
+ ), +})) + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileUploaderInAttachmentWrapper: ({ + onChange, + value, + }: { + onChange: (files?: FileEntity[]) => void + value: FileEntity[] + }) => ( +
+ + +
+ ), +})) + +const createPayloadChangeMock = () => { + const handlers = new Map() + const handlePayloadChange: PayloadChangeHandler = vi.fn((key: keyof InputVar) => { + const existing = handlers.get(key) + if (existing) + return existing + + const handler: PayloadValueHandler = vi.fn() + handlers.set(key, handler) + return handler + }) + + return { + handlePayloadChange, + handlers, + } +} + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: 'Name', + variable: 'name', + required: false, + hide: false, + default: '', + options: [], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['pdf'], + allowed_file_upload_methods: [TransferMethod.remote_url], + max_length: 1, + ...overrides, +}) + +const createState = (overrides: Partial = {}) => { + const payloadChange = createPayloadChangeMock() + + return { + state: { + checkboxDefaultSelectValue: 'false', + handleConfirm: vi.fn(), + handleJSONSchemaChange: vi.fn(), + handlePayloadChange: payloadChange.handlePayloadChange, + handleTypeChange: vi.fn(), + handleVarKeyBlur: vi.fn(), + handleVarNameChange: vi.fn(), + isStringInput: true, + jsonSchemaStr: '', + modelId: 'test-model', + modalRef: { current: null }, + selectOptions: [ + { value: InputVarType.textInput, name: 'Text Input' }, + { value: InputVarType.select, name: 'Select' }, + ], + setTempPayload: vi.fn(), + tempPayload: createInputVar(), + ...overrides, + } satisfies ConfigModalState, + payloadHandlers: payloadChange.handlers, + } +} + +describe('ConfigModalBody', () => { + beforeEach(() => { + vi.clearAllMocks() + selectOnValueChange = undefined + }) + + // Covers the default text-input layout and footer callbacks. + describe('text input mode', () => { + it('should render text fields and trigger footer actions', () => { + const { state, payloadHandlers } = createState() + const onClose = vi.fn() + + render() + + expect(screen.getAllByRole('textbox')).toHaveLength(3) + + fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'Display Name' } }) + fireEvent.change(screen.getAllByRole('textbox')[2], { target: { value: 'Hello world' } }) + fireEvent.click(screen.getAllByRole('checkbox')[0]) + fireEvent.click(screen.getAllByRole('checkbox')[1]) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(payloadHandlers.get('label')).toHaveBeenCalledWith('Display Name') + expect(payloadHandlers.get('default')).toHaveBeenCalledWith('Hello world') + expect(payloadHandlers.get('required')).toHaveBeenCalledWith(true) + expect(payloadHandlers.get('hide')).toHaveBeenCalledWith(true) + expect(state.handleConfirm).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should send updates for paragraph and number defaults', () => { + const paragraphState = createState({ + tempPayload: createInputVar({ + type: InputVarType.paragraph, + default: 'Paragraph value', + }), + }) + + const { container, unmount } = render() + + fireEvent.change(container.querySelector('textarea')!, { target: { value: 'Updated paragraph' } }) + expect(paragraphState.payloadHandlers.get('default')).toHaveBeenCalledWith('Updated paragraph') + + unmount() + + const numberState = createState({ + isStringInput: false, + tempPayload: createInputVar({ + type: InputVarType.number, + default: '5', + }), + }) + + const { container: numberContainer } = render() + + fireEvent.change(numberContainer.querySelector('input[type="number"]')!, { target: { value: '12' } }) + expect(numberState.payloadHandlers.get('default')).toHaveBeenCalledWith('12') + }) + }) + + // Covers select-specific sections and options wiring. + describe('select mode', () => { + it('should render select options and default selector when options exist', async () => { + const { state, payloadHandlers } = createState({ + isStringInput: false, + tempPayload: createInputVar({ + type: InputVarType.select, + default: 'A', + options: ['A', 'B'], + }), + }) + + render( + , + ) + + expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument() + expect(screen.getAllByText('A').length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' })) + fireEvent.click(screen.getByRole('option', { name: 'B' })) + + await waitFor(() => { + expect(state.handlePayloadChange).toHaveBeenCalledWith('default') + }) + expect(payloadHandlers.get('default')).toHaveBeenCalledWith('B') + }) + + it('should convert checkbox selections into boolean defaults', async () => { + const { state, payloadHandlers } = createState({ + isStringInput: false, + tempPayload: createInputVar({ + type: InputVarType.checkbox, + default: undefined, + }), + }) + + render() + + fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' })) + fireEvent.click(screen.getByRole('option', { name: 'appDebug.variableConfig.startChecked' })) + + await waitFor(() => { + expect(state.handlePayloadChange).toHaveBeenCalledWith('default') + }) + expect(payloadHandlers.get('default')).toHaveBeenCalledWith(true) + }) + }) + + // Covers file and JSON branches that were split out of the original entry component. + describe('specialized sections', () => { + it('should handle file settings and uploader changes for file inputs', () => { + const singleFileState = createState({ + isStringInput: false, + tempPayload: createInputVar({ + type: InputVarType.singleFile, + default: undefined, + }), + }) + + const { unmount } = render( + , + ) + + fireEvent.click(screen.getByText('update file setting')) + fireEvent.click(screen.getByText('upload one')) + + expect(singleFileState.state.setTempPayload).toHaveBeenCalledWith(expect.objectContaining({ max_length: 9 })) + expect(singleFileState.payloadHandlers.get('default')).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1' })) + + unmount() + + const multiFileState = createState({ + isStringInput: false, + tempPayload: createInputVar({ + type: InputVarType.multiFiles, + default: [] as unknown as InputVar['default'], + }), + }) + + render( + , + ) + + expect(screen.getByTestId('file-upload-setting')).toHaveTextContent('true') + expect(screen.getByTestId('file-uploader')).toBeInTheDocument() + + fireEvent.click(screen.getByText('upload many')) + expect(multiFileState.payloadHandlers.get('default')).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'file-1' }), + expect.objectContaining({ id: 'file-2' }), + ]) + }) + + it('should render the JSON schema editor and propagate edits for json object inputs', () => { + const { state } = createState({ + isStringInput: false, + jsonSchemaStr: '{\n "type": "object"\n}', + tempPayload: createInputVar({ + type: InputVarType.jsonObject, + default: undefined, + }), + }) + + render( + , + ) + + expect(screen.getByTestId('code-editor')).toHaveTextContent('"type": "object"') + fireEvent.click(screen.getByText('change json')) + expect(state.handleJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}') + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx new file mode 100644 index 0000000000..7ba53e6f4e --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import Field from '../field' + +describe('ConfigModal Field', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Covers the required field label rendering. + describe('Rendering', () => { + it('should render the field title and children content', () => { + render( + +
Child content
+
, + ) + + expect(screen.getByText('Field Title')).toBeInTheDocument() + expect(screen.getByText('Child content')).toBeInTheDocument() + }) + + it('should render the optional indicator when the field is optional', () => { + render( + +
Optional content
+
, + ) + + expect(screen.getByText('Optional Title')).toBeInTheDocument() + expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/helpers.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..a1ce9fc519 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/helpers.spec.ts @@ -0,0 +1,97 @@ +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { + applyTypeChange, + CHECKBOX_DEFAULT_FALSE_VALUE, + CHECKBOX_DEFAULT_TRUE_VALUE, + getCheckboxDefaultSelectValue, + isJsonSchemaEmpty, + normalizeSelectDefaultValue, + parseCheckboxSelectValue, +} from '../helpers' + +const createInputVar = (overrides = {}) => ({ + type: InputVarType.textInput, + label: 'Name', + variable: 'name', + required: false, + hide: false, + default: '', + options: [], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['pdf'], + allowed_file_upload_methods: [TransferMethod.local_file], + max_length: 1, + ...overrides, +}) + +describe('config-modal helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Covers checkbox default parsing for primitive and string payload values. + describe('checkbox defaults', () => { + it('should normalize checkbox default values to selector options', () => { + expect(getCheckboxDefaultSelectValue(true)).toBe(CHECKBOX_DEFAULT_TRUE_VALUE) + expect(getCheckboxDefaultSelectValue('true')).toBe(CHECKBOX_DEFAULT_TRUE_VALUE) + expect(getCheckboxDefaultSelectValue(false)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE) + expect(getCheckboxDefaultSelectValue(undefined)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE) + }) + + it('should convert selector values back to boolean defaults', () => { + expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_TRUE_VALUE)).toBe(true) + expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_FALSE_VALUE)).toBe(false) + }) + }) + + // Covers select normalization and JSON schema blank detection. + describe('normalization', () => { + it('should clear empty select defaults but keep other defaults unchanged', () => { + expect(normalizeSelectDefaultValue(createInputVar({ + type: InputVarType.select, + default: '', + }))).toEqual(expect.objectContaining({ default: undefined })) + + expect(normalizeSelectDefaultValue(createInputVar({ + type: InputVarType.textInput, + default: '', + }))).toEqual(expect.objectContaining({ default: '' })) + }) + + it('should detect empty JSON schema values', () => { + expect(isJsonSchemaEmpty(undefined)).toBe(true) + expect(isJsonSchemaEmpty(' ')).toBe(true) + expect(isJsonSchemaEmpty('{}')).toBe(false) + expect(isJsonSchemaEmpty({ type: 'object' })).toBe(false) + }) + }) + + // Covers type switching behavior for select and file input payloads. + describe('type changes', () => { + it('should reset select defaults when switching to select type', () => { + const nextPayload = applyTypeChange(createInputVar({ + default: 'hello', + }), InputVarType.select) + + expect(nextPayload.type).toBe(InputVarType.select) + expect(nextPayload.default).toBeUndefined() + }) + + it('should seed upload defaults when switching to multi-file type', () => { + const nextPayload = applyTypeChange(createInputVar({ + allowed_file_types: [], + allowed_file_extensions: [], + allowed_file_upload_methods: [], + max_length: 1, + }), InputVarType.multiFiles) + + expect(nextPayload).toEqual(expect.objectContaining({ + type: InputVarType.multiFiles, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + max_length: 5, + })) + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..990af8f990 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -0,0 +1,136 @@ +import type { InputVar } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { toast } from '@/app/components/base/ui/toast' +import { InputVarType } from '@/app/components/workflow/types' +import DebugConfigurationContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' +import ConfigModal from '../index' + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})) + +type DebugConfigValue = React.ComponentProps['value'] + +const createDebugConfigValue = (overrides: Partial = {}): DebugConfigValue => ({ + mode: AppModeEnum.CHAT, + modelConfig: { + model_id: 'test-model', + }, + ...overrides, +} as DebugConfigValue) + +const createAppDetail = (mode: AppModeEnum) => ({ + mode, +}) as NonNullable['appDetail']> + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: '', + variable: '', + required: false, + hide: false, + default: '', + ...overrides, +}) + +const renderConfigModal = ( + props: Partial> = {}, + debugOverrides: Partial = {}, +) => { + const defaultProps: React.ComponentProps = { + isCreate: true, + isShow: true, + onClose: vi.fn(), + onConfirm: vi.fn(), + payload: createInputVar(), + supportFile: false, + } + + const mergedProps = { + ...defaultProps, + ...props, + } + + return render( + + + , + ) +} + +describe('ConfigModal', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) }) + }) + + // Covers the main text-input save path through the rendered modal. + describe('Save flows', () => { + it('should auto-fill the label and submit the edited payload for text inputs', () => { + const onConfirm = vi.fn() + renderConfigModal({ onConfirm }) + + const textboxes = screen.getAllByRole('textbox') + fireEvent.change(textboxes[0], { target: { value: 'user name' } }) + fireEvent.blur(textboxes[0]) + fireEvent.change(textboxes[2], { target: { value: 'hello world' } }) + + expect(textboxes[1]).toHaveValue('user_name') + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ + variable: 'user_name', + label: 'user_name', + default: 'hello world', + }), { + type: 'changeVarName', + payload: { + beforeKey: '', + afterKey: 'user_name', + }, + }) + }) + + it('should block save and show an error when label is missing', () => { + const onConfirm = vi.fn() + renderConfigModal({ + onConfirm, + payload: createInputVar({ + label: '', + variable: 'name', + }), + }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onConfirm).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired') + }) + }) + + // Covers conditional sections for non-text variable types. + describe('Conditional rendering', () => { + it('should render select-specific sections when editing a select variable', () => { + renderConfigModal({ + payload: createInputVar({ + type: InputVarType.select, + label: 'Category', + variable: 'category', + options: ['A', 'B'], + default: 'A', + }), + }) + + expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument() + expect(screen.getAllByText('A').length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx new file mode 100644 index 0000000000..3b68bd925f --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import TypeSelector from '../type-select' + +const items = [ + { value: InputVarType.textInput, name: 'Text input' }, + { value: InputVarType.number, name: 'Number input' }, +] + +describe('ConfigModal TypeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Covers closed-state rendering and current selection display. + describe('Rendering', () => { + it('should render the current selected item label', () => { + render( + , + ) + + expect(screen.getByText('Text input')).toBeInTheDocument() + expect(screen.queryByText('Number input')).not.toBeInTheDocument() + }) + }) + + // Covers dropdown interactions and readonly behavior. + describe('User interactions', () => { + it('should call onSelect and close the list when choosing another item', () => { + const onSelect = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Text input')) + fireEvent.click(screen.getByText('Number input')) + + expect(onSelect).toHaveBeenCalledWith(items[1]) + expect(screen.queryByText('Number input')).not.toBeInTheDocument() + }) + + it('should keep the list closed when readonly is true', () => { + render( + , + ) + + fireEvent.click(screen.getByText('Text input')) + + expect(screen.queryByText('Number input')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/use-config-modal-state.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/use-config-modal-state.spec.tsx new file mode 100644 index 0000000000..a9a9680f3c --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/use-config-modal-state.spec.tsx @@ -0,0 +1,376 @@ +import type { ReactNode } from 'react' +import type { InputVar } from '@/app/components/workflow/types' +import { act, renderHook } from '@testing-library/react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { toast } from '@/app/components/base/ui/toast' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import DebugConfigurationContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' +import { useConfigModalState } from '../use-config-modal-state' + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})) + +type DebugConfigValue = React.ComponentProps['value'] + +const createDebugConfigValue = (overrides: Partial = {}): DebugConfigValue => ({ + mode: AppModeEnum.CHAT, + modelConfig: { + model_id: 'test-model', + }, + ...overrides, +} as DebugConfigValue) + +const createAppDetail = (mode: AppModeEnum) => ({ + mode, +}) as NonNullable['appDetail']> + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: 'Name', + variable: 'name', + required: false, + hide: false, + default: '', + ...overrides, +}) + +const renderConfigModalHook = ( + props: Partial[0]> = {}, + debugOverrides: Partial = {}, +) => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + return renderHook(() => useConfigModalState({ + isShow: true, + onConfirm: vi.fn(), + payload: createInputVar(), + supportFile: false, + ...props, + }), { wrapper }) +} + +describe('useConfigModalState', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) }) + }) + + // Covers app-mode dependent type options. + describe('select options', () => { + it('should expose JSON and file types for advanced apps with file support', () => { + useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) }) + const { result } = renderConfigModalHook({ + supportFile: true, + }) + + const optionValues = result.current.selectOptions.map(option => option.value) + expect(optionValues).toContain(InputVarType.singleFile) + expect(optionValues).toContain(InputVarType.multiFiles) + expect(optionValues).toContain(InputVarType.jsonObject) + }) + }) + + // Covers variable input normalization and label auto-fill. + describe('variable editing', () => { + it('should replace spaces with underscores and copy the value into label on blur', () => { + const { result } = renderConfigModalHook({ + payload: createInputVar({ + label: '', + variable: '', + }), + }) + + const input = document.createElement('input') + input.value = 'user name' + input.setSelectionRange(0, 0) + + act(() => { + result.current.handleVarNameChange({ target: input } as React.ChangeEvent) + }) + + expect(input.value).toBe('user_name') + expect(result.current.tempPayload.variable).toBe('user_name') + + act(() => { + result.current.handleVarKeyBlur({ target: input } as React.FocusEvent) + }) + + expect(result.current.tempPayload.label).toBe('user_name') + }) + + it('should reject invalid variable names and avoid overwriting an existing label on blur', () => { + const { result } = renderConfigModalHook({ + payload: createInputVar({ + label: 'Existing label', + variable: 'name', + }), + }) + + const invalidInput = document.createElement('input') + invalidInput.value = '1bad' + + act(() => { + result.current.handleVarNameChange({ target: invalidInput } as React.ChangeEvent) + }) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber')) + expect(result.current.tempPayload.variable).toBe('name') + + const blurInput = document.createElement('input') + blurInput.value = 'updated_name' + + act(() => { + result.current.handleVarKeyBlur({ target: blurInput } as React.FocusEvent) + }) + + expect(result.current.tempPayload.label).toBe('Existing label') + }) + }) + + // Covers payload mutation helpers and schema parsing. + describe('state helpers', () => { + it('should clear invalid select defaults and normalize json schema edits', () => { + const { result } = renderConfigModalHook({ + payload: createInputVar({ + type: InputVarType.select, + default: 'keep-me', + options: ['keep-me', 'next'], + }), + }) + + act(() => { + result.current.handlePayloadChange('options')(['next']) + }) + + expect(result.current.tempPayload.options).toEqual(['next']) + expect(result.current.tempPayload.default).toBeUndefined() + + act(() => { + result.current.handleJSONSchemaChange('{"type":"object"}') + }) + + expect(result.current.tempPayload.json_schema).toBe('{\n "type": "object"\n}') + + act(() => { + result.current.handleJSONSchemaChange(' ') + }) + + expect(result.current.tempPayload.json_schema).toBeUndefined() + expect(result.current.handleJSONSchemaChange('{invalid')).toBeNull() + }) + + it('should update the payload when changing types and expose file/json options for advanced apps', () => { + useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) }) + const { result } = renderConfigModalHook({ + supportFile: true, + }) + + act(() => { + result.current.handleTypeChange({ value: InputVarType.singleFile, name: 'Single File' }) + }) + + expect(result.current.tempPayload.type).toBe(InputVarType.singleFile) + expect(result.current.selectOptions.map(option => option.value)).toEqual(expect.arrayContaining([ + InputVarType.singleFile, + InputVarType.multiFiles, + InputVarType.jsonObject, + ])) + }) + }) + + // Covers confirm validation and metadata generation. + describe('confirm flows', () => { + it('should block duplicate select options and show an error toast', () => { + const onConfirm = vi.fn() + const { result } = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.select, + options: ['first', 'first'], + }), + }) + + act(() => { + result.current.handleConfirm() + }) + + expect(onConfirm).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.optionRepeat') + }) + + it('should block missing labels, invalid variable names, and incomplete file settings', () => { + const onConfirm = vi.fn() + const { result, rerender } = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + variable: '1bad', + label: 'Valid label', + }), + }) + + act(() => { + result.current.handleConfirm() + }) + + expect(onConfirm).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber')) + + rerender() + + const labelless = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + label: '', + }), + }) + + act(() => { + labelless.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired') + + const noFileTypes = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.singleFile, + label: 'File input', + default: undefined, + allowed_file_types: [], + }), + }) + + act(() => { + noFileTypes.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired')) + + const customWithoutExtensions = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.singleFile, + label: 'File input', + default: undefined, + allowed_file_types: [SupportUploadFileTypes.custom], + allowed_file_extensions: [], + }), + }) + + act(() => { + customWithoutExtensions.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired')) + }) + + it('should validate select and json object edge cases before saving', () => { + const onConfirm = vi.fn() + const emptyOptions = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.select, + options: [], + }), + }) + + act(() => { + emptyOptions.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.atLeastOneOption') + + const invalidJson = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.jsonObject, + label: 'JSON payload', + json_schema: '{invalid}', + }), + }) + + act(() => { + invalidJson.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaInvalid') + + const nonObjectJson = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.jsonObject, + label: 'JSON payload', + json_schema: '{"type":"array"}', + }), + }) + + act(() => { + nonObjectJson.result.current.handleConfirm() + }) + + expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaMustBeObject') + }) + + it('should omit empty JSON schema and include rename metadata when saving', () => { + const onConfirm = vi.fn() + const { result } = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + type: InputVarType.jsonObject, + json_schema: ' ', + variable: 'old_name', + label: 'JSON payload', + }), + }) + + act(() => { + result.current.handlePayloadChange('variable')('new_name') + }) + + act(() => { + result.current.handleConfirm() + }) + + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ + variable: 'new_name', + json_schema: undefined, + }), { + type: 'changeVarName', + payload: { + beforeKey: 'old_name', + afterKey: 'new_name', + }, + }) + }) + + it('should save without rename metadata when the variable name does not change', () => { + const onConfirm = vi.fn() + const { result } = renderConfigModalHook({ + onConfirm, + payload: createInputVar({ + variable: 'same_name', + label: 'Same name', + }), + }) + + act(() => { + result.current.handleConfirm() + }) + + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ + variable: 'same_name', + }), undefined) + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/config-modal-body.tsx b/web/app/components/app/configuration/config-var/config-modal/config-modal-body.tsx new file mode 100644 index 0000000000..6786a6d502 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/config-modal-body.tsx @@ -0,0 +1,297 @@ +'use client' + +import type { FC } from 'react' +import type { ConfigModalState } from './use-config-modal-state' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import Checkbox from '@/app/components/base/checkbox' +import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/app/components/base/ui/select' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import ConfigSelect from '../config-select' +import ConfigString from '../config-string' +import ModalFoot from '../modal-foot' +import { jsonConfigPlaceHolder } from './config' +import Field from './field' +import { + CHECKBOX_DEFAULT_FALSE_VALUE, + CHECKBOX_DEFAULT_TRUE_VALUE, + FILE_INPUT_TYPES, + parseCheckboxSelectValue, + TEXT_MAX_LENGTH, +} from './helpers' +import TypeSelector from './type-select' + +type Props = { + onClose: () => void + state: ConfigModalState +} + +type SelectOption = { + value: string + name: string +} + +type InlineSelectProps = { + value: string + items: SelectOption[] + placeholder: string + onChange: (value: string) => void +} + +const InlineSelect = ({ + value, + items, + placeholder, + onChange, +}: InlineSelectProps) => { + return ( + + ) +} + +const EMPTY_DEFAULT_VALUE = '__empty-default__' + +const ConfigModalBody: FC = ({ + state, + onClose, +}) => { + const { t } = useTranslation() + const { + checkboxDefaultSelectValue, + handleConfirm, + handleJSONSchemaChange, + handlePayloadChange, + handleTypeChange, + handleVarKeyBlur, + handleVarNameChange, + isStringInput, + jsonSchemaStr, + modelId, + modalRef, + selectOptions, + setTempPayload, + tempPayload, + } = state + + const { type, label, max_length, options, variable } = tempPayload + const isFileInput = FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number]) + + return ( + <> +
+
+ + + + + + + + + + handlePayloadChange('label')(event.target.value)} + placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} + /> + + + {isStringInput && ( + + void} + /> + + )} + + {type === InputVarType.textInput && ( + + handlePayloadChange('default')(event.target.value || undefined)} + placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} + /> + + )} + + {type === InputVarType.paragraph && ( + +