From 2ff50514c85694e2b12f48eed065197e0498ede2 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 8 May 2026 09:23:32 +0800 Subject: [PATCH] refactor: migrate app selector to combobox (#35896) --- .../model-provider-page/model-modal/Form.tsx | 2 +- .../model-modal/__tests__/Form.spec.tsx | 7 +- .../__tests__/app-picker.spec.tsx | 181 -- .../__tests__/app-trigger.spec.tsx | 46 - .../app-selector/__tests__/index.spec.tsx | 2687 +---------------- .../app-selector/app-picker.tsx | 325 +- .../app-selector/app-trigger.tsx | 54 +- .../app-selector/index.tsx | 245 +- .../__tests__/reasoning-config-form.spec.tsx | 7 +- .../components/reasoning-config-form.tsx | 2 +- .../form-input-item.branches.spec.tsx | 11 +- .../_base/components/form-input-item.tsx | 2 +- .../__tests__/input-var-list.spec.tsx | 5 +- .../nodes/tool/components/input-var-list.tsx | 2 +- .../__tests__/tag-filter.spec.tsx | 13 + .../__tests__/tag-selector.spec.tsx | 16 + .../tag-management/components/tag-panel.tsx | 5 +- .../components/tag-selector.tsx | 2 +- 18 files changed, 460 insertions(+), 3152 deletions(-) delete mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx delete mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 385bd84f90..fe84ddea13 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -18,7 +18,7 @@ import { useCallback, useState } from 'react' import { Infotip } from '@/app/components/base/infotip' import Radio from '@/app/components/base/radio' import RadioE from '@/app/components/base/radio/ui' -import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector' import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector' diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx index a71cddcd24..11ff189393 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/Form.spec.tsx @@ -8,6 +8,7 @@ import type { CredentialFormSchemaTextInput, FormValue, } from '../../declarations' +import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -29,8 +30,8 @@ vi.mock('../../hooks', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ - default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => ( - + AppSelector: ({ onSelect }: { onSelect: (item: AppSelectorValue) => void }) => ( + ), })) @@ -408,7 +409,7 @@ describe('Form', () => { multi_tool: [{ id: 'tool-1' }], })) expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - app_selector: { id: 'app-1', type: FormTypeEnum.appSelector }, + app_selector: { app_id: 'app-1', inputs: {}, files: [], type: FormTypeEnum.appSelector }, })) }) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx deleted file mode 100644 index af3f97c889..0000000000 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { ReactNode } from 'react' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { AppModeEnum } from '@/types/app' -import AppPicker from '../app-picker' - -class MockIntersectionObserver { - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -class MockMutationObserver { - observe = vi.fn() - disconnect = vi.fn() - takeRecords = vi.fn().mockReturnValue([]) -} - -beforeAll(() => { - vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) - vi.stubGlobal('MutationObserver', MockMutationObserver) -}) - -vi.mock('@/app/components/base/app-icon', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/base/input', () => ({ - default: ({ - value, - onChange, - onClear, - }: { - value: string - onChange: (e: { target: { value: string } }) => void - onClear?: () => void - }) => ( -
- onChange({ target: { value: e.target.value } })} - /> - -
- ), -})) - -vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ - children, - open, - }: { - children: ReactNode - open: boolean - }) => ( -
- {children} -
- ), - PopoverTrigger: ({ - children, - render, - onClick, - }: { - children: ReactNode - render?: ReactNode - onClick?: () => void - }) => ( - - ), - PopoverContent: ({ children }: { children: ReactNode }) => ( -
{children}
- ), -})) - -const apps = [ - { - id: 'app-1', - name: 'Chat App', - mode: AppModeEnum.CHAT, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#fff', - }, - { - id: 'app-2', - name: 'Workflow App', - mode: AppModeEnum.WORKFLOW, - icon_type: 'emoji', - icon: '⚙️', - icon_background: '#fff', - }, -] - -describe('AppPicker', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should open when the trigger is clicked', () => { - const onShowChange = vi.fn() - - render( - Trigger} - isShow={false} - onShowChange={onShowChange} - onSelect={vi.fn()} - apps={apps as never} - isLoading={false} - hasMore={false} - onLoadMore={vi.fn()} - searchText="" - onSearchChange={vi.fn()} - />, - ) - - fireEvent.click(screen.getByTestId('picker-trigger')) - - expect(onShowChange).toHaveBeenCalledWith(true) - }) - - it('should render apps, select one, and handle search changes', () => { - const onSelect = vi.fn() - const onSearchChange = vi.fn() - - render( - Trigger} - isShow - onShowChange={vi.fn()} - onSelect={onSelect} - apps={apps as never} - isLoading={false} - hasMore={false} - onLoadMore={vi.fn()} - searchText="chat" - onSearchChange={onSearchChange} - />, - ) - - fireEvent.change(screen.getByTestId('search-input'), { - target: { value: 'workflow' }, - }) - fireEvent.click(screen.getByText('Workflow App')) - fireEvent.click(screen.getByTestId('clear-input')) - - expect(onSearchChange).toHaveBeenCalledWith('workflow') - expect(onSearchChange).toHaveBeenCalledWith('') - expect(onSelect).toHaveBeenCalledWith(apps[1]) - expect(screen.getByText('chat')).toBeInTheDocument() - }) - - it('should render loading text when loading more apps', () => { - render( - Trigger} - isShow - onShowChange={vi.fn()} - onSelect={vi.fn()} - apps={apps as never} - isLoading - hasMore - onLoadMore={vi.fn()} - searchText="" - onSearchChange={vi.fn()} - />, - ) - - expect(screen.getByText('common.loading')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx deleted file mode 100644 index 1b6ac7f1f0..0000000000 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-trigger.spec.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ size }: { size: string }) =>
, -})) - -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - -describe('AppTrigger', () => { - let AppTrigger: (typeof import('../app-trigger'))['default'] - - beforeEach(async () => { - vi.clearAllMocks() - const mod = await import('../app-trigger') - AppTrigger = mod.default - }) - - it('should render placeholder when no app is selected', () => { - render() - - expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument() - }) - - it('should render app details when appDetail is provided', () => { - const appDetail = { - name: 'My App', - icon_type: 'emoji', - icon: '🤖', - icon_background: '#fff', - } - render() - - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - expect(screen.getByText('My App')).toBeInTheDocument() - }) - - it('should render when open', () => { - const { container } = render() - - expect(container.firstChild).toBeInTheDocument() - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index bdaa79a1a2..8810d35b28 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -1,2622 +1,201 @@ import type { ReactNode } from 'react' +import type { AppSelectorValue } from '../index' import type { App } from '@/types/app' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { InputVarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' -import AppInputsForm from '../app-inputs-form' -import AppInputsPanel from '../app-inputs-panel' -import AppPicker from '../app-picker' -import AppTrigger from '../app-trigger' +import { AppSelector } from '../index' -import AppSelector from '../index' - -// ==================== Mock Setup ==================== - -const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) - -// Mock IntersectionObserver globally using class syntax -let intersectionObserverCallback: IntersectionObserverCallback | null = null -const mockIntersectionObserver = { - observe: vi.fn(), - disconnect: vi.fn(), - unobserve: vi.fn(), - root: null, - rootMargin: '', - thresholds: [], - takeRecords: vi.fn().mockReturnValue([]), -} as unknown as IntersectionObserver - -// Helper function to trigger intersection observer callback -const triggerIntersection = (entries: IntersectionObserverEntry[]) => { - if (intersectionObserverCallback) { - intersectionObserverCallback(entries, mockIntersectionObserver) - } -} - -class MockIntersectionObserver { - constructor(callback: IntersectionObserverCallback) { - intersectionObserverCallback = callback - } - - observe = vi.fn() - disconnect = vi.fn() - unobserve = vi.fn() -} - -// Mock MutationObserver globally using class syntax -let mutationObserverCallback: MutationCallback | null = null - -class MockMutationObserver { - constructor(callback: MutationCallback) { - mutationObserverCallback = callback - } - - observe = vi.fn() - disconnect = vi.fn() - takeRecords = vi.fn().mockReturnValue([]) -} - -// Helper function to trigger mutation observer callback -const triggerMutationObserver = () => { - if (mutationObserverCallback) { - mutationObserverCallback([], new MockMutationObserver(() => {})) - } -} - -// Set up global mocks before tests -beforeAll(() => { - vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) - vi.stubGlobal('MutationObserver', MockMutationObserver) -}) - -afterAll(() => { - vi.unstubAllGlobals() -}) - -// Mock portal components for controlled positioning in tests -// Use React context to properly scope open state per popover instance (for nested popovers) -vi.mock('@langgenius/dify-ui/popover', () => { - // Context reference shared across mock components - let sharedContext: React.Context | null = null - - // Lazily get or create the context - const getContext = (): React.Context => { - if (!sharedContext) { - const PopoverOpenContext = React.createContext(false) - sharedContext = PopoverOpenContext - } - return sharedContext - } - - return { - Popover: ({ - children, - open, - }: { - children: ReactNode - open?: boolean - }) => { - const Context = getContext() - return React.createElement( - Context.Provider, - { value: open || false }, - React.createElement('div', { 'data-testid': 'popover', 'data-open': open }, children), - ) - }, - PopoverTrigger: ({ - children, - render, - onClick, - className, - }: { - children: ReactNode - render?: ReactNode - onClick?: () => void - className?: string - }) => ( -
- {render ?? children} -
- ), - PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => { - const Context = getContext() - const isOpen = React.useContext(Context) - if (!isOpen) - return null - return ( -
{children}
- ) - }, - } -}) - -// Mock service hooks -let mockAppListData: { pages: Array<{ data: App[], has_more: boolean, page: number }> } | undefined -let mockIsLoading = false -let mockIsFetchingNextPage = false -let mockHasNextPage = true -const mockFetchNextPage = vi.fn() - -// Allow configurable mock data for useAppDetail -let mockAppDetailData: App | undefined | null -let mockAppDetailLoading = false - -// Helper to get app detail data - avoids nested ternary and hoisting issues -const getAppDetailData = (appId: string) => { - if (mockAppDetailData !== undefined) - return mockAppDetailData - if (!appId) - return undefined - // Extract number from appId (e.g., 'app-1' -> '1') for consistent naming with createMockApps - const appNumber = appId.replace('app-', '') - // Return a basic mock app structure - return { - id: appId, - name: `App ${appNumber}`, - mode: 'chat', +const apps: App[] = [ + { + id: 'app-1', + name: 'Support Bot', + mode: AppModeEnum.CHAT, icon_type: 'emoji', icon: '🤖', icon_background: '#FFEAD5', - model_config: { user_input_form: [] }, - } -} - -vi.mock('@/service/use-apps', () => ({ - useAppDetail: (appId: string) => ({ - data: getAppDetailData(appId), - isFetching: mockAppDetailLoading, - }), -})) + model_config: { + user_input_form: [], + }, + } as unknown as App, + { + id: 'app-2', + name: 'Workflow App', + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + icon: '⚙️', + icon_background: '#E0EAFF', + } as unknown as App, +] vi.mock('@/service/client', () => ({ consoleQuery: { apps: { list: { - infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), + infiniteOptions: ({ + input, + getNextPageParam, + initialPageParam, + placeholderData, + }: { + input: (pageParam: number) => { query: { name?: string } } + getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined + initialPageParam: number + placeholderData: unknown + }) => ({ + queryKey: ['apps', input(1).query], + queryFn: ({ pageParam = initialPageParam }: { pageParam?: number }) => { + const query = input(Number(pageParam)).query + const keyword = query.name?.toLowerCase() ?? '' + const filteredApps = keyword + ? apps.filter(app => app.name.toLowerCase().includes(keyword)) + : apps + + return { + data: filteredApps, + has_more: false, + page: Number(pageParam), + } + }, + getNextPageParam, + initialPageParam, + placeholderData, + }), }, }, }, })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useInfiniteQuery: () => ({ - data: mockAppListData, - isLoading: mockIsLoading, - isFetchingNextPage: mockIsFetchingNextPage, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockHasNextPage, - }), - } -}) +vi.mock('@/service/use-apps', () => ({ + useAppDetail: (appId: string) => ({ + data: apps.find(app => app.id === appId), + }), +})) -// Allow configurable mock data for useAppWorkflow -let mockWorkflowData: Record | undefined | null -let mockWorkflowLoading = false - -// Helper to get workflow data - avoids nested ternary -const getWorkflowData = (appId: string) => { - if (mockWorkflowData !== undefined) - return mockWorkflowData - if (!appId) - return undefined - return { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { type: 'text-input', label: 'Name', variable: 'name', required: false }, - ], - }, - }, - ], - }, - features: {}, - } -} +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: undefined }), +})) vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: (appId: string) => ({ - data: getWorkflowData(appId), - isFetching: mockWorkflowLoading, - }), + useAppWorkflow: () => ({ data: undefined, isFetching: false }), })) -// Mock common service -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { - image_file_size_limit: 10, - file_size_limit: 15, - audio_file_size_limit: 50, - video_file_size_limit: 100, - workflow_file_upload_limit: 10, - }, - }), -})) - -// Mock file uploader -vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ onChange, value }: { onChange: (files: unknown[]) => void, value: unknown[] }) => ( -
- {JSON.stringify(value)} - - -
- ), -})) - -// Mock Select for testing select field interactions -vi.mock('@langgenius/dify-ui/select', async () => { - const React = await import('react') - const SelectContext = React.createContext<{ - onValueChange?: (value: string) => void - }>({}) - - return { - Select: ({ children, onValueChange }: { - children: React.ReactNode - onValueChange?: (value: string) => void - }) => ( - -
{children}
-
- ), - SelectTrigger: ({ children }: { children: React.ReactNode }) => ( - {children} - ), - SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { - const context = React.useContext(SelectContext) - return ( - - ) - }, - SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}, - SelectItemIndicator: () => null, - } -}) - -// Mock Input component with onClear support -vi.mock('@/app/components/base/input', () => ({ - default: ({ onChange, onClear, value, showClearIcon, ...props }: { - onChange: (e: { target: { value: string } }) => void - onClear?: () => void - value: string - showClearIcon?: boolean - placeholder?: string - }) => ( -
- - {showClearIcon && onClear && ( - - )} -
- ), -})) - -// ==================== Test Utilities ==================== - -const createTestQueryClient = () => - new QueryClient({ +function renderWithQueryClient(children: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, + queries: { + retry: false, + }, }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createTestQueryClient() return render( - {ui} + {children} , ) } -type AppSelectorInfiniteOptions = { - input: (pageParam: number) => { query: Record } - getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined +function StatefulAppSelector({ + onSelect, +}: { + onSelect: (value: AppSelectorValue) => void +}) { + const [value, setValue] = useState() + + return ( + { + setValue(nextValue) + onSelect(nextValue) + }} + /> + ) } -// Mock data factories -const createMockApp = (overrides: Record = {}): App => ({ - id: 'app-1', - name: 'Test App', - description: 'A test app', - mode: AppModeEnum.CHAT, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - icon_url: null, - use_icon_as_answer_icon: false, - enable_site: true, - enable_api: true, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: { - provider: 'openai', - model_id: 'gpt-4', - model: { - provider: 'openai', - name: 'gpt-4', - mode: 'chat', - completion_params: {}, - }, - configs: { - prompt_template: '', - prompt_variables: [], - completion_params: {}, - }, - opening_statement: '', - suggested_questions: [], - suggested_questions_after_answer: { enabled: false }, - speech_to_text: { enabled: false }, - text_to_speech: { enabled: false, voice: '', language: '' }, - retriever_resource: { enabled: false }, - annotation_reply: { enabled: false }, - more_like_this: { enabled: false }, - sensitive_word_avoidance: { enabled: false }, - external_data_tools: [], - dataSets: [], - agentMode: { enabled: false, strategy: null, tools: [] }, - chatPromptConfig: {}, - completionPromptConfig: {}, - file_upload: {}, - user_input_form: [], - }, - app_model_config: {}, - created_at: Date.now(), - updated_at: Date.now(), - site: {}, - api_base_url: '', - tags: [], - access_mode: 'public', - ...overrides, -} as unknown as App) - -// Helper function to get app mode based on index -const getAppModeByIndex = (index: number): AppModeEnum => { - if (index % 5 === 0) - return AppModeEnum.ADVANCED_CHAT - if (index % 4 === 0) - return AppModeEnum.AGENT_CHAT - if (index % 3 === 0) - return AppModeEnum.WORKFLOW - if (index % 2 === 0) - return AppModeEnum.COMPLETION - return AppModeEnum.CHAT -} - -const createMockApps = (count: number): App[] => { - return Array.from({ length: count }, (_, i) => - createMockApp({ - id: `app-${i + 1}`, - name: `App ${i + 1}`, - mode: getAppModeByIndex(i), - })) -} - -// ==================== AppTrigger Tests ==================== - -describe('AppTrigger', () => { - describe('Rendering', () => { - it('should render placeholder when no app is selected', () => { - render() - // i18n mock returns key with namespace in dot format - // i18n mock returns key with namespace in dot format - expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() - }) - - it('should render app details when app is selected', () => { - const app = createMockApp({ name: 'My Test App' }) - render() - expect(screen.getByText('My Test App'))!.toBeInTheDocument() - }) - - it('should apply open state styling', () => { - const { container } = render() - const trigger = container.querySelector('.bg-state-base-hover-alt') - expect(trigger)!.toBeInTheDocument() - }) - - it('should render AppIcon when app is provided', () => { - const app = createMockApp() - const { container } = render() - // AppIcon renders with a specific class when app is provided - const iconContainer = container.querySelector('.mr-2') - expect(iconContainer)!.toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should handle undefined appDetail gracefully', () => { - render() - expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() - }) - - it('should display app name with title attribute', () => { - const app = createMockApp({ name: 'Long App Name For Testing' }) - render() - const nameElement = screen.getByTitle('Long App Name For Testing') - expect(nameElement)!.toBeInTheDocument() - }) - }) - - describe('Styling', () => { - it('should have correct base classes', () => { - const { container } = render() - const trigger = container.firstChild as HTMLElement - expect(trigger)!.toHaveClass('group', 'flex', 'cursor-pointer') - }) - - it('should apply different padding when app is provided', () => { - const app = createMockApp() - const { container } = render() - const trigger = container.firstChild as HTMLElement - expect(trigger)!.toHaveClass('py-1.5', 'pl-1.5') - }) - }) -}) - -// ==================== AppPicker Tests ==================== - -describe('AppPicker', () => { - const defaultProps = { - scope: 'all', - disabled: false, - trigger: , - placement: 'right-start' as const, - offset: 0, - isShow: false, - onShowChange: vi.fn(), - onSelect: vi.fn(), - apps: createMockApps(5), - isLoading: false, - hasMore: false, - onLoadMore: vi.fn(), - searchText: '', - onSearchChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('Rendering', () => { - it('should render trigger element', () => { - render() - expect(screen.getByText('Select App'))!.toBeInTheDocument() - }) - - it('should render app list when open', () => { - render() - expect(screen.getByText('App 1'))!.toBeInTheDocument() - expect(screen.getByText('App 2'))!.toBeInTheDocument() - }) - - it('should show loading indicator when isLoading is true', () => { - render() - expect(screen.getByText('common.loading'))!.toBeInTheDocument() - }) - - it('should not render content when isShow is false', () => { - render() - expect(screen.queryByText('App 1')).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onSelect when app is clicked', () => { - const onSelect = vi.fn() - render() - - fireEvent.click(screen.getByText('App 1')) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'app-1' })) - }) - - it('should call onSearchChange when typing in search input', () => { - const onSearchChange = vi.fn() - render() - - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'test' } }) - expect(onSearchChange).toHaveBeenCalledWith('test') - }) - - it('should not call onShowChange when disabled', () => { - const onShowChange = vi.fn() - render() - - fireEvent.click(screen.getByTestId('popover-trigger')) - expect(onShowChange).not.toHaveBeenCalled() - }) - - it('should call onShowChange when trigger is clicked and not disabled', () => { - const onShowChange = vi.fn() - render() - - fireEvent.click(screen.getByTestId('popover-trigger')) - expect(onShowChange).toHaveBeenCalledWith(true) - }) - }) - - describe('App Type Display', () => { - it('should display correct app type for CHAT', () => { - const apps = [createMockApp({ id: 'chat-app', name: 'Chat App', mode: AppModeEnum.CHAT })] - render() - expect(screen.getByText('chat'))!.toBeInTheDocument() - }) - - it('should display correct app type for WORKFLOW', () => { - const apps = [createMockApp({ id: 'workflow-app', name: 'Workflow App', mode: AppModeEnum.WORKFLOW })] - render() - expect(screen.getByText('workflow'))!.toBeInTheDocument() - }) - - it('should display correct app type for ADVANCED_CHAT', () => { - const apps = [createMockApp({ id: 'chatflow-app', name: 'Chatflow App', mode: AppModeEnum.ADVANCED_CHAT })] - render() - expect(screen.getByText('chatflow'))!.toBeInTheDocument() - }) - - it('should display correct app type for AGENT_CHAT', () => { - const apps = [createMockApp({ id: 'agent-app', name: 'Agent App', mode: AppModeEnum.AGENT_CHAT })] - render() - expect(screen.getByText('agent'))!.toBeInTheDocument() - }) - - it('should display correct app type for COMPLETION', () => { - const apps = [createMockApp({ id: 'completion-app', name: 'Completion App', mode: AppModeEnum.COMPLETION })] - render() - expect(screen.getByText('completion'))!.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty apps array', () => { - render() - expect(screen.queryByRole('listitem')).not.toBeInTheDocument() - }) - - it('should handle search text with value', () => { - render() - const input = screen.getByTestId('input') - expect(input)!.toHaveValue('test search') - }) - }) - - describe('Search Clear', () => { - it('should call onSearchChange with empty string when clear button is clicked', () => { - const onSearchChange = vi.fn() - render() - - const clearBtn = screen.getByTestId('clear-btn') - fireEvent.click(clearBtn) - expect(onSearchChange).toHaveBeenCalledWith('') - }) - }) - - describe('Infinite Scroll', () => { - it('should not call onLoadMore when isLoading is true', () => { - const onLoadMore = vi.fn() - - render() - - // Simulate intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // onLoadMore should not be called because isLoading blocks it - expect(onLoadMore).not.toHaveBeenCalled() - }) - - it('should not call onLoadMore when hasMore is false', () => { - const onLoadMore = vi.fn() - - render() - - // Simulate intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // onLoadMore should not be called when hasMore is false - expect(onLoadMore).not.toHaveBeenCalled() - }) - - it('should call onLoadMore when intersection observer fires and conditions are met', () => { - const onLoadMore = vi.fn() - - render() - - // Simulate intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(onLoadMore).toHaveBeenCalled() - }) - - it('should not call onLoadMore when target is not intersecting', () => { - const onLoadMore = vi.fn() - - render() - - // Simulate non-intersecting - triggerIntersection([{ isIntersecting: false } as IntersectionObserverEntry]) - - expect(onLoadMore).not.toHaveBeenCalled() - }) - - it('should handle observer target ref', () => { - render() - - // The component should render without errors - // The component should render without errors - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle isShow toggle correctly', () => { - const { rerender } = render() - - // Change isShow to true - rerender() - - // Then back to false - rerender() - - // Should not crash - // Should not crash - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should setup intersection observer when isShow is true', () => { - render() - - // IntersectionObserver callback should have been set - expect(intersectionObserverCallback).not.toBeNull() - }) - - it('should disconnect observer when isShow changes from true to false', () => { - const { rerender } = render() - - // Verify observer was set up - expect(intersectionObserverCallback).not.toBeNull() - - // Change to not shown - should disconnect observer (lines 74-75) - rerender() - - // Component should render without errors - // Component should render without errors - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should cleanup observer on component unmount', () => { - const { unmount } = render() - - // Unmount should trigger cleanup without throwing - expect(() => unmount()).not.toThrow() - }) - - it('should handle MutationObserver callback when target becomes available', () => { - render() - - // Trigger MutationObserver callback (simulates DOM change) - triggerMutationObserver() - - // Component should still work correctly - // Component should still work correctly - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should not setup IntersectionObserver when observerTarget is null', () => { - // When isShow is false, the observer target won't be in the DOM - render() - - // The guard at line 84 should prevent setup - // The guard at line 84 should prevent setup - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should debounce onLoadMore calls using loadingRef', () => { - const onLoadMore = vi.fn() - - render() - - // First intersection should trigger onLoadMore - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(1) - - // Second immediate intersection should be blocked by loadingRef - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - // Still only called once due to loadingRef debounce - expect(onLoadMore).toHaveBeenCalledTimes(1) - - // After 500ms timeout, loadingRef should reset - act(() => { - vi.advanceTimersByTime(600) - }) - - // Now it can be called again - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(2) - }) - - it('should reset loadingRef when the picker closes before the debounce timeout finishes', () => { - const onLoadMore = vi.fn() - const { rerender } = render( - , - ) - - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(1) - - rerender() - rerender() - - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(2) - }) - - it('should reset loadingRef when the picker unmounts before the debounce timeout finishes', () => { - const onLoadMore = vi.fn() - const { unmount } = render( - , - ) - - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(1) - - unmount() - - render() - - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(onLoadMore).toHaveBeenCalledTimes(2) - }) - }) - - describe('Memoization', () => { - it('should be wrapped with React.memo', () => { - expect(AppPicker).toBeDefined() - const onSelect = vi.fn() - const { rerender } = render() - rerender() - }) - }) -}) - -// ==================== AppInputsForm Tests ==================== - -describe('AppInputsForm', () => { - const mockInputsRef = { current: {} as Record } - - const defaultProps = { - inputsForms: [], - inputs: {} as Record, - inputsRef: mockInputsRef, - onFormChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockInputsRef.current = {} - }) - - describe('Rendering', () => { - it('should return null when inputsForms is empty', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should render text input field', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - expect(screen.getByText('Name'))!.toBeInTheDocument() - expect(screen.getByPlaceholderText('Name'))!.toBeInTheDocument() - }) - - it('should render number input field', () => { - const forms = [ - { type: InputVarType.number, label: 'Count', variable: 'count', required: false }, - ] - render() - expect(screen.getByText('Count'))!.toBeInTheDocument() - }) - - it('should render paragraph (textarea) field', () => { - const forms = [ - { type: InputVarType.paragraph, label: 'Description', variable: 'desc', required: false }, - ] - render() - expect(screen.getByText('Description'))!.toBeInTheDocument() - }) - - it('should render select field', () => { - const forms = [ - { type: InputVarType.select, label: 'Select Option', variable: 'option', options: ['a', 'b'], required: false }, - ] - render() - // Label and placeholder both contain "Select Option" - expect(screen.getAllByText(/Select Option/).length).toBeGreaterThanOrEqual(1) - }) - - it('should render file uploader for single file', () => { - const forms = [ - { - type: InputVarType.singleFile, - label: 'Single File Upload', - variable: 'file', - required: false, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }, - ] - render() - expect(screen.getByText('Single File Upload'))!.toBeInTheDocument() - expect(screen.getByTestId('file-uploader'))!.toBeInTheDocument() - }) - - it('should render file uploader for single file with existing value', () => { - const existingFile = { id: 'existing-file-1', name: 'test.png' } - const forms = [ - { - type: InputVarType.singleFile, - label: 'Single File', - variable: 'singleFile', - required: false, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }, - ] - render() - // The file uploader should receive the existing file as an array - // The file uploader should receive the existing file as an array - expect(screen.getByTestId('file-value'))!.toHaveTextContent(JSON.stringify([existingFile])) - }) - - it('should render file uploader for multi files', () => { - const forms = [ - { - type: InputVarType.multiFiles, - label: 'Attachments', - variable: 'files', - required: false, - max_length: 5, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png', '.jpg'], - allowed_file_upload_methods: ['local_file'], - }, - ] - render() - expect(screen.getByText('Attachments'))!.toBeInTheDocument() - }) - - it('should show optional label for non-required fields', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - expect(screen.getByText('workflow.panel.optional'))!.toBeInTheDocument() - }) - - it('should not show optional label for required fields', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: true }, - ] - render() - expect(screen.queryByText('workflow.panel.optional')).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onFormChange when text input changes', () => { - const onFormChange = vi.fn() - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - - const input = screen.getByPlaceholderText('Name') - fireEvent.change(input, { target: { value: 'test value' } }) - - expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'test value' })) - }) - - it('should call onFormChange when number input changes', () => { - const onFormChange = vi.fn() - const forms = [ - { type: InputVarType.number, label: 'Count', variable: 'count', required: false }, - ] - render() - - const input = screen.getByPlaceholderText('Count') - fireEvent.change(input, { target: { value: '42' } }) - - expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ count: '42' })) - }) - - it('should call onFormChange when textarea changes', () => { - const onFormChange = vi.fn() - const forms = [ - { type: InputVarType.paragraph, label: 'Description', variable: 'desc', required: false }, - ] - render() - - const textarea = screen.getByPlaceholderText('Description') - fireEvent.change(textarea, { target: { value: 'long text' } }) - - expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ desc: 'long text' })) - }) - - it('should call onFormChange when file is uploaded', () => { - const onFormChange = vi.fn() - const forms = [ - { - type: InputVarType.singleFile, - label: 'Upload', - variable: 'file', - required: false, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }, - ] - render() - - fireEvent.click(screen.getByTestId('upload-file-btn')) - expect(onFormChange).toHaveBeenCalled() - }) - - it('should call onFormChange when select option is clicked', () => { - const onFormChange = vi.fn() - const forms = [ - { type: InputVarType.select, label: 'Color', variable: 'color', options: ['red', 'blue'], required: false }, - ] - render() - - // Click on select option - fireEvent.click(screen.getByTestId('select-option-red')) - expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ color: 'red' })) - }) - - it('should call onFormChange when multiple files are uploaded', () => { - const onFormChange = vi.fn() - const forms = [ - { - type: InputVarType.multiFiles, - label: 'Files', - variable: 'files', - required: false, - max_length: 5, - allowed_file_types: ['image'], - allowed_file_extensions: ['.png'], - allowed_file_upload_methods: ['local_file'], - }, - ] - render() - - fireEvent.click(screen.getByTestId('upload-multi-files-btn')) - expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ - files: [{ id: 'file-1' }, { id: 'file-2' }], - })) - }) - }) - - describe('Callback Stability', () => { - it('should preserve reference to handleFormChange with useCallback', () => { - const onFormChange = vi.fn() - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - - const { rerender } = render( - , - ) - - // Change inputs without changing onFormChange - rerender( - , - ) - - const input = screen.getByPlaceholderText('Name') - fireEvent.change(input, { target: { value: 'updated' } }) - - expect(onFormChange).toHaveBeenCalled() - }) - }) - - describe('Edge Cases', () => { - it('should handle inputs with existing values', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - - const input = screen.getByPlaceholderText('Name') - expect(input)!.toHaveValue('existing') - }) - - it('should handle empty string value', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - - const input = screen.getByPlaceholderText('Name') - expect(input)!.toHaveValue('') - }) - - it('should handle undefined variable value', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - ] - render() - - const input = screen.getByPlaceholderText('Name') - expect(input)!.toHaveValue('') - }) - - it('should handle multiple form fields', () => { - const forms = [ - { type: InputVarType.textInput, label: 'Name', variable: 'name', required: false }, - { type: InputVarType.number, label: 'Age', variable: 'age', required: false }, - { type: InputVarType.paragraph, label: 'Bio', variable: 'bio', required: false }, - ] - render() - - expect(screen.getByText('Name'))!.toBeInTheDocument() - expect(screen.getByText('Age'))!.toBeInTheDocument() - expect(screen.getByText('Bio'))!.toBeInTheDocument() - }) - - it('should handle unknown form type gracefully', () => { - const forms = [ - { type: 'unknown-type' as InputVarType, label: 'Unknown', variable: 'unknown', required: false }, - ] - // Should not throw error, just not render the field - render() - expect(screen.getByText('Unknown'))!.toBeInTheDocument() - }) - }) -}) - -// ==================== AppInputsPanel Tests ==================== - -describe('AppInputsPanel', () => { - const defaultProps = { - value: { app_id: 'app-1', inputs: {} }, - appDetail: createMockApp({ mode: AppModeEnum.CHAT }), - onFormChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockAppDetailData = undefined - mockAppDetailLoading = false - mockWorkflowData = undefined - mockWorkflowLoading = false - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should show no params message when form schema is empty', () => { - renderWithQueryClient() - expect(screen.getByText('app.appSelector.noParams'))!.toBeInTheDocument() - }) - - it('should show loading state when app is loading', () => { - mockAppDetailLoading = true - renderWithQueryClient() - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - // Loading component should be rendered - expect(screen.queryByText('app.appSelector.params')).not.toBeInTheDocument() - }) - - it('should show loading state when workflow is loading', () => { - mockWorkflowLoading = true - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.queryByText('app.appSelector.params')).not.toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should handle undefined value', () => { - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should handle different app modes', () => { - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should handle advanced chat mode', () => { - const advancedChatApp = createMockApp({ mode: AppModeEnum.ADVANCED_CHAT }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) - - describe('Form Schema Generation - Basic App', () => { - it('should generate schema for paragraph input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { paragraph: { label: 'Description', variable: 'desc' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for number input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { number: { label: 'Count', variable: 'count' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for checkbox input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { checkbox: { label: 'Enabled', variable: 'enabled' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for select input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { select: { label: 'Option', variable: 'option', options: ['a', 'b'] } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for file-list input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'file-list': { label: 'Files', variable: 'files' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for file input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { file: { label: 'File', variable: 'file' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for json_object input', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { json_object: { label: 'JSON', variable: 'json' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for text-input (default)', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'Name', variable: 'name' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should filter external_data_tool items', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'Name', variable: 'name' }, 'external_data_tool': true }, - { 'text-input': { label: 'Email', variable: 'email' } }, - ], - }, - }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) - - describe('Form Schema Generation - Workflow App', () => { - it('should generate schema for workflow with multiFiles variable', () => { - mockWorkflowData = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { type: 'file-list', label: 'Files', variable: 'files' }, - ], - }, - }, - ], - }, - features: {}, - } - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for workflow with singleFile variable', () => { - mockWorkflowData = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { type: 'file', label: 'File', variable: 'file' }, - ], - }, - }, - ], - }, - features: {}, - } - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should generate schema for workflow with regular variable', () => { - mockWorkflowData = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { type: 'text-input', label: 'Name', variable: 'name' }, - ], - }, - }, - ], - }, - features: {}, - } - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) - - describe('Image Upload Schema', () => { - it('should add image upload schema for COMPLETION mode with file upload enabled', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.COMPLETION, - model_config: { - ...createMockApp().model_config, - file_upload: { - enabled: true, - image: { enabled: true }, - }, - user_input_form: [], - }, - }) - const completionApp = createMockApp({ mode: AppModeEnum.COMPLETION }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should add image upload schema for WORKFLOW mode with file upload enabled', () => { - mockAppDetailData = createMockApp({ - mode: AppModeEnum.WORKFLOW, - model_config: { - ...createMockApp().model_config, - file_upload: { - enabled: true, - }, - user_input_form: [], - }, - }) - mockWorkflowData = { - graph: { nodes: [{ data: { type: 'start', variables: [] } }] }, - features: { file_upload: { enabled: true } }, - } - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onFormChange when form is updated', () => { - const onFormChange = vi.fn() - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - - it('should call onFormChange with updated values when text input changes', () => { - const onFormChange = vi.fn() - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'TestField', variable: 'testField', default: '', required: false, max_length: 100 } }, - ], - }, - }) - renderWithQueryClient() - - // Find and change the text input - const input = screen.getByPlaceholderText('TestField') - fireEvent.change(input, { target: { value: 'new value' } }) - - // handleFormChange should be called with the new value - expect(onFormChange).toHaveBeenCalledWith({ testField: 'new value' }) - }) - - it('should update inputsRef when form changes', () => { - const onFormChange = vi.fn() - mockAppDetailData = createMockApp({ - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'RefTestField', variable: 'refField', default: '', required: false, max_length: 50 } }, - ], - }, - }) - renderWithQueryClient() - - const input = screen.getByPlaceholderText('RefTestField') - fireEvent.change(input, { target: { value: 'ref updated' } }) - - expect(onFormChange).toHaveBeenCalledWith({ refField: 'ref updated' }) - }) - }) - - describe('Memoization', () => { - it('should memoize basicAppFileConfig correctly', () => { - const { rerender } = renderWithQueryClient() - rerender( - - - , - ) - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should return empty schema when currentApp is null', () => { - mockAppDetailData = null - renderWithQueryClient() - expect(screen.getByText('app.appSelector.noParams'))!.toBeInTheDocument() - }) - - it('should handle workflow without start node', () => { - mockWorkflowData = { - graph: { nodes: [] }, - features: {}, - } - const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW }) - renderWithQueryClient() - expect(screen.getByText('app.appSelector.params'))!.toBeInTheDocument() - }) - }) -}) - -// ==================== AppSelector (Main Component) Tests ==================== - describe('AppSelector', () => { - const defaultProps = { - onSelect: vi.fn(), - } - beforeEach(() => { vi.clearAllMocks() - vi.useFakeTimers() - mockAppListData = { - pages: [{ data: createMockApps(5), has_more: false, page: 1 }], - } - mockIsLoading = false - mockIsFetchingNextPage = false - mockHasNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - mockAppDetailData = undefined - mockAppDetailLoading = false - mockWorkflowData = undefined - mockWorkflowLoading = false }) - afterEach(() => { - vi.useRealTimers() - }) + it('should keep the main interaction: outer panel, inner app list, then inputs panel', async () => { + const onSelect = vi.fn() - describe('Rendering', () => { - it('should render without crashing', () => { - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() + renderWithQueryClient() + + fireEvent.click(screen.getByRole('button', { name: 'app.appSelector.label' })) + expect(screen.getByText('app.appSelector.label')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) + + await waitFor(() => { + expect(screen.getByText('Support Bot')).toBeInTheDocument() }) - it('should render trigger component', () => { - renderWithQueryClient() - expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() + fireEvent.click(screen.getByText('Support Bot')) + + expect(onSelect).toHaveBeenCalledWith({ + app_id: 'app-1', + inputs: {}, + files: [], }) - - it('should configure paged app list query options', () => { - renderWithQueryClient() - - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppSelectorInfiniteOptions - - expect(options.input(4)).toEqual({ - query: { - page: 4, - limit: 20, - name: '', - }, - }) - expect(options.getNextPageParam({ has_more: true, page: 4 })).toBe(5) - expect(options.getNextPageParam({ has_more: false, page: 4 })).toBeUndefined() - }) - - it('should show selected app info when value is provided', () => { - renderWithQueryClient( - , - ) - // Should show the app trigger with app info - // Should show the app trigger with app info - expect(screen.getByTestId('popover'))!.toBeInTheDocument() + expect(screen.getByText('app.appSelector.label')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'app.appSelector.label' })).toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByText('Workflow App')).not.toBeInTheDocument() }) }) - describe('Props', () => { - it('should handle different placement values', () => { - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() + it('should search apps from the content input', async () => { + renderWithQueryClient() + + fireEvent.click(screen.getByRole('button', { name: 'app.appSelector.label' })) + fireEvent.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) + fireEvent.change(screen.getByRole('combobox', { name: 'app.appSelector.placeholder' }), { + target: { value: 'workflow' }, }) - it('should handle different offset values', () => { - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle disabled state', () => { - renderWithQueryClient() - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - // Portal should remain closed when disabled - // Portal should remain closed when disabled - expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false') - }) - - it('should handle scope prop', () => { - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle value with inputs', () => { - renderWithQueryClient( - , - ) - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle value with files', () => { - renderWithQueryClient( - , - ) - expect(screen.getByTestId('popover'))!.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Workflow App')).toBeInTheDocument() }) + expect(screen.queryByText('Support Bot')).not.toBeInTheDocument() }) - describe('State Management', () => { - it('should toggle isShow state when trigger is clicked', () => { - renderWithQueryClient() + it('should not keep the selected app in filtered results', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() - const trigger = screen.getAllByTestId('popover-trigger')[0] - fireEvent.click(trigger!) + renderWithQueryClient() - // The portal state should update synchronously - get the first one (outer portal) - // The portal state should update synchronously - get the first one (outer portal) - expect(screen.getAllByTestId('popover')[0])!.toHaveAttribute('data-open', 'true') + await user.click(screen.getByRole('button', { name: 'app.appSelector.label' })) + await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) + + await waitFor(() => { + expect(screen.getByText('Support Bot')).toBeInTheDocument() }) - it('should not toggle isShow when disabled', () => { - renderWithQueryClient() + await user.click(screen.getByText('Support Bot')) + await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) + await user.type(screen.getByRole('combobox', { name: 'app.appSelector.placeholder' }), 'workflow') - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false') + await waitFor(() => { + expect(screen.queryByRole('option', { name: /Support Bot/ })).not.toBeInTheDocument() }) + expect(screen.getByRole('option', { name: /Workflow App/ })).toBeInTheDocument() - it('should manage search text state', () => { - renderWithQueryClient() + await user.keyboard('{ArrowDown}') + await user.keyboard('{Enter}') - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - // Portal content should be visible after click - // Portal content should be visible after click - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should render correctly during load more setup', () => { - mockHasNextPage = true - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - - renderWithQueryClient() - - // Trigger should be rendered - // Trigger should be rendered - expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() - }) - }) - - describe('Callbacks', () => { - it('should call onSelect when app is selected', () => { - const onSelect = vi.fn() - - renderWithQueryClient() - - // Open the portal - fireEvent.click(screen.getByTestId('popover-trigger')) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should call onSelect with correct value structure', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - // The component should maintain the correct value structure - // The component should maintain the correct value structure - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should clear inputs when selecting different app', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - // Component renders with existing value - // Component renders with existing value - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should preserve inputs when selecting same app', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) - - describe('Memoization', () => { - it('should memoize displayedApps correctly', () => { - mockAppListData = { - pages: [ - { data: createMockApps(3), has_more: true, page: 1 }, - { data: createMockApps(3), has_more: false, page: 2 }, - ], - } - - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should memoize currentAppInfo correctly', () => { - mockAppListData = { - pages: [{ data: createMockApps(5), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should memoize formattedValue correctly', () => { - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should be wrapped with React.memo', () => { - // Verify the component is defined and memoized - expect(AppSelector).toBeDefined() - - const onSelect = vi.fn() - const { rerender } = renderWithQueryClient() - - // Re-render with same props should not cause unnecessary updates - rerender( - - - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) - - describe('Load More Functionality', () => { - it('should handle load more when hasMore is true', async () => { - mockHasNextPage = true - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should not trigger load more when already loading', async () => { - mockIsFetchingNextPage = true - mockHasNextPage = true - renderWithQueryClient() - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - - it('should not trigger load more when no more data', () => { - mockHasNextPage = false - renderWithQueryClient() - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - - it('should handle fetchNextPage completion with delay', async () => { - mockHasNextPage = true - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should render load more area when hasMore is true', () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open the portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Should render without errors - // Should render without errors - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should handle fetchNextPage rejection gracefully in handleLoadMore', async () => { - mockHasNextPage = true - mockFetchNextPage.mockRejectedValue(new Error('Network error')) - - renderWithQueryClient() - - // Should not crash even if fetchNextPage rejects - // Should not crash even if fetchNextPage rejects - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should call fetchNextPage when intersection observer triggers handleLoadMore', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Open the inner app picker portal - const triggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(triggers[1]!) - - // Simulate intersection to trigger handleLoadMore - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // fetchNextPage should be called - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should avoid duplicate fetches while the picker debounce is active', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - const triggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(triggers[1]!) - - // Trigger first intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - // Try to trigger again immediately - should be blocked by AppPicker loadingRef - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // Still only one call due to the picker-level debounce - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - }) - - it('should skip handleLoadMore when isFetchingNextPage is true', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = true // This will block the handleLoadMore - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - const triggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(triggers[1]!) - - // Trigger intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // fetchNextPage should NOT be called because isFetchingNextPage is true - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - - it('should skip handleLoadMore when hasMore is false', async () => { - mockHasNextPage = false // This will block the handleLoadMore - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - const triggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(triggers[1]!) - - // Trigger intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // fetchNextPage should NOT be called because hasMore is false - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - describe('Form Change Handling', () => { - it('should handle form change with image file', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle form change without image file', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should extract #image# from inputs and add to files array', () => { - const onSelect = vi.fn() - // The handleFormChange function should extract #image# and add to files - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should preserve existing files when no #image# in inputs', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) - - describe('App Selection', () => { - it('should clear inputs when selecting a different app', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should preserve inputs when selecting the same app', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle app selection with empty value', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined value', () => { - renderWithQueryClient() - expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() - }) - - it('should handle empty pages array', () => { - mockAppListData = { pages: [] } - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle undefined data', () => { - mockAppListData = undefined - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle loading state', () => { - mockIsLoading = true - renderWithQueryClient() - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle app not found in displayedApps', () => { - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle value with empty inputs and files', () => { - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) - - describe('Error Handling', () => { - it('should handle fetchNextPage rejection gracefully', async () => { - mockHasNextPage = true - mockFetchNextPage.mockRejectedValue(new Error('Network error')) - - renderWithQueryClient() - - // Should not crash - // Should not crash - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) -}) - -// ==================== Integration Tests ==================== - -describe('AppSelector Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - mockAppListData = { - pages: [{ data: createMockApps(5), has_more: false, page: 1 }], - } - mockIsLoading = false - mockIsFetchingNextPage = false - mockHasNextPage = false - mockAppDetailData = undefined - mockAppDetailLoading = false - mockWorkflowData = undefined - mockWorkflowLoading = false - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('Full User Flow', () => { - it('should complete full app selection flow', () => { - const onSelect = vi.fn() - - renderWithQueryClient() - - // 1. Click trigger to open picker - get first trigger (outer portal) - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Get the first portal element (outer portal) - // Get the first portal element (outer portal) - expect(screen.getAllByTestId('popover')[0])!.toHaveAttribute('data-open', 'true') - }) - - it('should handle app change with input preservation logic', () => { - const onSelect = vi.fn() - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - }) - - describe('Component Communication', () => { - it('should pass correct props to AppTrigger', () => { - renderWithQueryClient() - - // AppTrigger should show placeholder when no app selected - // AppTrigger should show placeholder when no app selected - expect(screen.getByText('app.appSelector.placeholder'))!.toBeInTheDocument() - }) - - it('should pass correct props to AppPicker', () => { - renderWithQueryClient() - - fireEvent.click(screen.getByTestId('popover-trigger')) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - }) - - describe('Data Flow', () => { - it('should properly format value with files for AppInputsPanel', () => { - renderWithQueryClient( - , - ) - - expect(screen.getByTestId('popover'))!.toBeInTheDocument() - }) - - it('should handle search filtering through app list', () => { - renderWithQueryClient() - - fireEvent.click(screen.getByTestId('popover-trigger')) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - }) - - describe('handleSelectApp Callback', () => { - it('should call onSelect with new app when selecting different app', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // The inner AppPicker portal is closed by default (isShowChooseApp = false) - // We need to click on the inner trigger to open it - const innerTriggers = screen.getAllByTestId('popover-trigger') - // The second trigger is the inner AppPicker trigger - fireEvent.click(innerTriggers[1]!) - - // Now the inner portal should be open and show the app list - // Find and click on app-2 - const app2 = screen.getByText('App 2') - fireEvent.click(app2) - - // onSelect should be called with cleared inputs since it's a different app - expect(onSelect).toHaveBeenCalledWith({ + await waitFor(() => { + expect(onSelect).toHaveBeenLastCalledWith({ app_id: 'app-2', inputs: {}, files: [], }) }) - - it('should preserve inputs when selecting same app', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Click on the inner trigger to open app picker - const innerTriggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(innerTriggers[1]!) - - // Click on the same app - need to get the one in the app list, not the trigger - const appItems = screen.getAllByText('App 1') - // The last one should be in the dropdown list - fireEvent.click(appItems[appItems.length - 1]!) - - // onSelect should be called with preserved inputs since it's the same app - expect(onSelect).toHaveBeenCalledWith({ - app_id: 'app-1', - inputs: { existing: 'value' }, - files: [{ id: 'existing-file' }], - }) - }) - - it('should handle app selection when value is undefined', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open the main portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Click on inner trigger to open app picker - const innerTriggers = screen.getAllByTestId('popover-trigger') - fireEvent.click(innerTriggers[1]!) - - // Click on an app from the dropdown - const app1Elements = screen.getAllByText('App 1') - fireEvent.click(app1Elements[app1Elements.length - 1]!) - - // onSelect should be called with new app and empty inputs/files - expect(onSelect).toHaveBeenCalledWith({ - app_id: 'app-1', - inputs: {}, - files: [], - }) - }) - }) - - describe('handleLoadMore Callback', () => { - it('should handle load more by calling fetchNextPage', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open the portal to render the app picker - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should stay stable after fetchNextPage completes', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() - }) - - it('should not call fetchNextPage when conditions prevent it', () => { - mockHasNextPage = false - mockIsFetchingNextPage = true - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // fetchNextPage should not be called - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('handleFormChange Callback', () => { - it('should format value correctly with files for display', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - // Open portal - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // formattedValue should include #image# from files - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - }) - - it('should handle value with no files', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - }) - - it('should handle undefined value.files', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - }) - - it('should call onSelect with transformed inputs when form input changes', () => { - const onSelect = vi.fn() - // Include app-1 in the list so currentAppInfo is found - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - // Setup mock app detail with form fields - ensure complete form config - mockAppDetailData = createMockApp({ - id: 'app-1', - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'FormInputField', variable: 'formVar', default: '', required: false, max_length: 100 } }, - ], - }, - }) - - renderWithQueryClient( - , - ) - - // Open portal to render AppInputsPanel - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Find and interact with the form input (may not exist if schema is empty) - const formInputs = screen.queryAllByPlaceholderText('FormInputField') - if (formInputs.length > 0) { - fireEvent.change(formInputs[0]!, { target: { value: 'test value' } }) - - // handleFormChange in index.tsx should have been called - expect(onSelect).toHaveBeenCalledWith({ - app_id: 'app-1', - inputs: { formVar: 'test value' }, - files: [], - }) - } - else { - // If form inputs aren't rendered, at least verify component rendered - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - } - }) - - it('should extract #image# field from inputs and add to files array', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - // Setup COMPLETION mode app with file upload enabled for #image# field - // The #image# schema is added when basicAppFileConfig.enabled is true - mockAppDetailData = createMockApp({ - id: 'app-1', - mode: AppModeEnum.COMPLETION, - model_config: { - ...createMockApp().model_config, - file_upload: { - enabled: true, - image: { - enabled: true, - number_limits: 1, - detail: 'high', - transfer_methods: ['local_file'], - }, - }, - user_input_form: [], - }, - }) - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Find file uploader and trigger upload - the #image# field will be extracted - const uploadBtns = screen.queryAllByTestId('upload-file-btn') - if (uploadBtns.length > 0) { - fireEvent.click(uploadBtns[0]!) - // handleFormChange should extract #image# and convert to files - expect(onSelect).toHaveBeenCalled() - } - else { - // Verify component rendered - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - } - }) - - it('should preserve existing files when inputs do not contain #image#', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - mockAppDetailData = createMockApp({ - id: 'app-1', - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'PreserveField', variable: 'name', default: '', required: false, max_length: 50 } }, - ], - }, - }) - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Find form input (may not exist if schema is empty) - const inputs = screen.queryAllByPlaceholderText('PreserveField') - if (inputs.length > 0) { - fireEvent.change(inputs[0]!, { target: { value: 'updated name' } }) - - // onSelect should be called preserving existing files (no #image# in inputs) - expect(onSelect).toHaveBeenCalledWith({ - app_id: 'app-1', - inputs: { name: 'updated name' }, - files: [{ id: 'preserved-file' }], - }) - } - else { - // If form inputs aren't rendered, at least verify component rendered - expect(screen.getAllByTestId('popover-content').length).toBeGreaterThan(0) - } - }) - - it('should handle handleFormChange with #image# field and convert to files', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - // Setup COMPLETION app with file upload - this will add #image# to form schema - mockAppDetailData = createMockApp({ - id: 'app-1', - mode: AppModeEnum.COMPLETION, - model_config: { - ...createMockApp().model_config, - file_upload: { - enabled: true, - image: { - enabled: true, - number_limits: 1, - detail: 'high', - transfer_methods: ['local_file'], - }, - }, - user_input_form: [], - }, - }) - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - // Try to find and click the upload button which triggers #image# form change - const uploadBtn = screen.queryByTestId('upload-file-btn') - if (uploadBtn) { - fireEvent.click(uploadBtn) - // handleFormChange should be called and extract #image# to files - expect(onSelect).toHaveBeenCalled() - } - }) - - it('should handle handleFormChange without #image# and preserve value files', () => { - const onSelect = vi.fn() - mockAppListData = { - pages: [{ data: createMockApps(3), has_more: false, page: 1 }], - } - mockAppDetailData = createMockApp({ - id: 'app-1', - mode: AppModeEnum.CHAT, - model_config: { - ...createMockApp().model_config, - user_input_form: [ - { 'text-input': { label: 'SimpleInput', variable: 'simple', default: '', required: false, max_length: 100 } }, - ], - }, - }) - - renderWithQueryClient( - , - ) - - fireEvent.click(screen.getAllByTestId('popover-trigger')[0]!) - - const inputs = screen.queryAllByPlaceholderText('SimpleInput') - if (inputs.length > 0) { - fireEvent.change(inputs[0]!, { target: { value: 'changed' } }) - // handleFormChange should preserve existing files when no #image# in inputs - expect(onSelect).toHaveBeenCalledWith({ - app_id: 'app-1', - inputs: { simple: 'changed' }, - files: [{ id: 'pre-existing-file' }], - }) - } - }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index cf387b1715..9c1f40af90 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -1,28 +1,32 @@ 'use client' -import type { - OffsetOptions, - Placement, -} from '@floating-ui/react' -import type { FC } from 'react' + +import type { Placement } from '@langgenius/dify-ui/combobox' +import type { ReactNode } from 'react' import type { App } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import * as React from 'react' -import { useCallback, useEffect, useRef } from 'react' + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemText, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Input from '@/app/components/base/input' import { AppModeEnum } from '@/types/app' -type Props = { - scope: string +type AppPickerProps = { + scope?: string disabled: boolean - trigger: React.ReactNode + trigger: ReactNode placement?: Placement - offset?: OffsetOptions + offset?: number isShow: boolean onShowChange: (isShow: boolean) => void onSelect: (app: App) => void @@ -34,8 +38,62 @@ type Props = { onSearchChange: (text: string) => void } -const AppPicker: FC = ({ - scope: _scope, +function getAppTypeLabel(app: App) { + switch (app.mode) { + case AppModeEnum.ADVANCED_CHAT: + return 'chatflow' + case AppModeEnum.AGENT_CHAT: + return 'agent' + case AppModeEnum.CHAT: + return 'chat' + case AppModeEnum.COMPLETION: + return 'completion' + case AppModeEnum.WORKFLOW: + return 'workflow' + default: + return app.mode + } +} + +function getAppSearchText(app: App) { + return `${app.name} ${app.id} ${getAppTypeLabel(app)}` +} + +function AppPickerOption({ + app, +}: { + app: App +}) { + return ( + + + + + {app.name} + + ( + {app.id.slice(0, 8)} + ) + + + + {getAppTypeLabel(app)} + + ) +} + +export function AppPicker({ disabled, trigger, placement = 'right-start', @@ -49,186 +107,91 @@ const AppPicker: FC = ({ onLoadMore, searchText, onSearchChange, -}) => { +}: AppPickerProps) { const { t } = useTranslation() - const observerTargetRef = useRef(null) - const observerRef = useRef(null) - const loadingRef = useRef(false) - const loadingResetTimerIdRef = useRef(undefined) - const retimeLoadingReset = useCallback((timerId?: number) => { - if (loadingResetTimerIdRef.current !== undefined) - globalThis.clearTimeout(loadingResetTimerIdRef.current) - - loadingResetTimerIdRef.current = timerId - }, []) - - const resetLoadingState = useCallback(() => { - retimeLoadingReset() - loadingRef.current = false - }, [retimeLoadingReset]) - - const disconnectObserver = useCallback(() => { - if (!observerRef.current) + const handleValueChange = useCallback((app: App | null) => { + if (!app) return - observerRef.current.disconnect() - observerRef.current = null - }, []) - - const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { - const target = entries[0] - if (!target!.isIntersecting || loadingRef.current || !hasMore || isLoading) - return - - loadingRef.current = true - onLoadMore() - retimeLoadingReset(window.setTimeout(() => { - loadingRef.current = false - retimeLoadingReset() - }, 500)) - }, [hasMore, isLoading, onLoadMore, retimeLoadingReset]) - - useEffect(() => { - if (!isShow) { - resetLoadingState() - disconnectObserver() - return - } - - let mutationObserver: MutationObserver | null = null - - const setupIntersectionObserver = () => { - if (!observerTargetRef.current) - return - - disconnectObserver() - - // Create new observer - observerRef.current = new IntersectionObserver(handleIntersection, { - root: null, - rootMargin: '100px', - threshold: 0.1, - }) - - observerRef.current.observe(observerTargetRef.current) - } - - // Set up MutationObserver to watch DOM changes - mutationObserver = new MutationObserver((_mutations) => { - if (observerTargetRef.current) { - setupIntersectionObserver() - mutationObserver?.disconnect() - } - }) - - // Watch body changes since Portal adds content to body - mutationObserver.observe(document.body, { - childList: true, - subtree: true, - }) - - // If element exists, set up IntersectionObserver directly - if (observerTargetRef.current) - setupIntersectionObserver() - - return () => { - resetLoadingState() - disconnectObserver() - mutationObserver?.disconnect() - } - }, [disconnectObserver, handleIntersection, isShow, resetLoadingState]) - - const getAppType = (app: App) => { - switch (app.mode) { - case AppModeEnum.ADVANCED_CHAT: - return 'chatflow' - case AppModeEnum.AGENT_CHAT: - return 'agent' - case AppModeEnum.CHAT: - return 'chat' - case AppModeEnum.COMPLETION: - return 'completion' - case AppModeEnum.WORKFLOW: - return 'workflow' - } - } - - const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset - const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 - const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 - const handleTriggerClick = useCallback((event: React.MouseEvent) => { - event.preventDefault() - if (disabled || isShow) - return - - onShowChange(true) - }, [disabled, isShow, onShowChange]) + onSelect(app) + onShowChange(false) + }, [onSelect, onShowChange]) return ( - + items={apps} open={isShow} + inputValue={searchText} onOpenChange={onShowChange} + onInputValueChange={onSearchChange} + onValueChange={handleValueChange} + itemToStringLabel={app => app?.name ?? ''} + itemToStringValue={app => app?.id ?? ''} + filter={(app, query) => getAppSearchText(app).toLowerCase().includes(query.toLowerCase())} + disabled={disabled} > - {trigger}
} - onClick={handleTriggerClick} - /> - - + {trigger} + + -
+
- onSearchChange(e.target.value)} - onClear={() => onSearchChange('')} - /> + +
- {apps.map(app => ( -
onSelect(app)} - > - -
- {app.name} - - ( - {app.id.slice(0, 8)} - ) - -
-
{getAppType(app)}
-
- ))} -
- {isLoading && ( -
-
{t('loading', { ns: 'common' })}
-
+ {isLoading && ( + + {t('loading', { ns: 'common' })} + + )} + + {(app: App) => ( + )} -
+ + + {t('noData', { ns: 'common' })} + + {hasMore && ( +
+ +
+ )}
- - + + ) } - -export default React.memo(AppPicker) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx index aacafb3c31..d6f2c15bcf 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx @@ -1,33 +1,32 @@ 'use client' + import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, -} from '@remixicon/react' -import * as React from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -type Props = { +type AppTriggerProps = { open: boolean appDetail?: App } -const AppTrigger = ({ +export function AppTrigger({ open, appDetail, -}: Props) => { +}: AppTriggerProps) { const { t } = useTranslation() + return ( -
{appDetail && ( )} - {appDetail && ( -
{appDetail.name}
- )} - {!appDetail && ( -
{t('appSelector.placeholder', { ns: 'app' })}
- )} - -
+ {appDetail + ? ( + + {appDetail.name} + + ) + : ( + + {t('appSelector.placeholder', { ns: 'app' })} + + )} + ) } - -export default AppTrigger diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 5068143a8d..8d3094a2a0 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -1,10 +1,6 @@ 'use client' -import type { - OffsetOptions, - Placement, -} from '@floating-ui/react' -import type { FC } from 'react' -import type { AppListQuery } from '@/contract/console/apps' + +import type { Placement } from '@langgenius/dify-ui/popover' import type { App } from '@/types/app' import { Popover, @@ -12,48 +8,44 @@ import { PopoverTrigger, } from '@langgenius/dify-ui/popover' import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' -import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel' -import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker' -import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger' +import { AppPicker } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker' +import { AppTrigger } from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger' import { consoleQuery } from '@/service/client' import { useAppDetail } from '@/service/use-apps' const PAGE_SIZE = 20 -type Props = { - value?: { - app_id: string - inputs: Record - files?: unknown[] - } +export type AppSelectorValue = { + app_id: string + inputs: Record + files?: unknown[] +} + +type AppSelectorProps = { + value?: AppSelectorValue scope?: string disabled?: boolean placement?: Placement - offset?: OffsetOptions - onSelect: (app: { - app_id: string - inputs: Record - files?: unknown[] - }) => void - supportAddCustomTool?: boolean + offset?: number + onSelect: (app: AppSelectorValue) => void } -const AppSelector: FC = ({ +export function AppSelector({ value, - scope, disabled, placement = 'bottom', offset = 4, onSelect, -}) => { +}: AppSelectorProps) { const { t } = useTranslation() const [isShow, setIsShow] = useState(false) + const [isShowChooseApp, setIsShowChooseApp] = useState(false) const [searchText, setSearchText] = useState('') - const appListQuery = useMemo(() => ({ + const appListQuery = useMemo(() => ({ page: 1, limit: PAGE_SIZE, name: searchText, @@ -80,150 +72,105 @@ const AppSelector: FC = ({ }) const displayedApps = useMemo(() => { - const pages = data?.pages ?? [] - if (!pages.length) - return [] - return pages.flatMap(({ data: apps }) => apps) + return data?.pages.flatMap(({ data: apps }) => apps) ?? [] }, [data?.pages]) - // fetch selected app by id to avoid pagination gaps const { data: selectedAppDetail } = useAppDetail(value?.app_id || '') - // Ensure the currently selected app is available for display and in the picker options const currentAppInfo = useMemo(() => { if (!value?.app_id) return undefined + return selectedAppDetail || displayedApps.find(app => app.id === value.app_id) - }, [value?.app_id, selectedAppDetail, displayedApps]) - - const appsForPicker = useMemo(() => { - if (!currentAppInfo) - return displayedApps - - const appIndex = displayedApps.findIndex(a => a.id === currentAppInfo.id) - - if (appIndex === -1) - return [currentAppInfo, ...displayedApps] - - const updatedApps = [...displayedApps] - updatedApps[appIndex] = currentAppInfo - return updatedApps - }, [currentAppInfo, displayedApps]) + }, [displayedApps, selectedAppDetail, value?.app_id]) const hasMore = hasNextPage ?? true - const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset - const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 - const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 - const handleLoadMore = useCallback(async () => { - if (isFetchingNextPage || !hasMore) - return + const handleSelectApp = useCallback((app: App) => { + const shouldClearValue = app.id !== value?.app_id - await fetchNextPage() - }, [fetchNextPage, hasMore, isFetchingNextPage]) - - const handleTriggerClick = useCallback((event: React.MouseEvent) => { - event.preventDefault() - if (disabled || isShow) - return - - setIsShow(true) - }, [disabled, isShow]) - - const [isShowChooseApp, setIsShowChooseApp] = useState(false) - const handleSelectApp = (app: App) => { - const clearValue = app.id !== value?.app_id - const appValue = { + onSelect({ app_id: app.id, - inputs: clearValue ? {} : value?.inputs || {}, - files: clearValue ? [] : value?.files || [], - } - onSelect(appValue) - setIsShowChooseApp(false) - } + inputs: shouldClearValue ? {} : value?.inputs || {}, + files: shouldClearValue ? [] : value?.files || [], + }) + }, [onSelect, value?.app_id, value?.files, value?.inputs]) - const handleFormChange = (inputs: Record) => { + const handleFormChange = useCallback((inputs: Record) => { const newFiles = inputs['#image#'] - delete inputs['#image#'] - const newValue = { - app_id: value?.app_id || '', - inputs, - files: newFiles ? [newFiles] : value?.files || [], - } - onSelect(newValue) - } + const nextInputs = { ...inputs } + delete nextInputs['#image#'] - const formattedValue = useMemo(() => { - return { + onSelect({ app_id: value?.app_id || '', - inputs: { - ...value?.inputs, - ...(value?.files?.length ? { '#image#': value.files[0] } : {}), - }, - } - }, [value]) + inputs: nextInputs, + files: newFiles ? [newFiles] : value?.files || [], + }) + }, [onSelect, value?.app_id, value?.files]) + + const formattedValue = useMemo(() => ({ + app_id: value?.app_id || '', + inputs: { + ...value?.inputs, + ...(value?.files?.length ? { '#image#': value.files[0] } : {}), + }, + }), [value]) return ( - <> - + } > - - -
- )} - onClick={handleTriggerClick} + - -
-
-
{t('appSelector.label', { ns: 'app' })}
- - )} - isShow={isShowChooseApp} - onShowChange={setIsShowChooseApp} - disabled={false} - onSelect={handleSelectApp} - scope={scope || 'all'} - apps={appsForPicker} - isLoading={isLoading || isFetchingNextPage} - hasMore={hasMore} - onLoadMore={handleLoadMore} - searchText={searchText} - onSearchChange={setSearchText} - /> -
- {/* app inputs config panel */} - {currentAppInfo && ( - - )} + + +
+
+
{t('appSelector.label', { ns: 'app' })}
+ + )} + isShow={isShowChooseApp} + onShowChange={setIsShowChooseApp} + disabled={false} + onSelect={handleSelectApp} + apps={displayedApps} + isLoading={isLoading || isFetchingNextPage} + hasMore={hasMore} + onLoadMore={() => { + void fetchNextPage() + }} + searchText={searchText} + onSearchChange={setSearchText} + />
- - - + {currentAppInfo && ( + + )} +
+
+ ) } - -export default React.memo(AppSelector) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index f7853a1fcd..016eda373d 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -62,11 +63,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ - default: ({ onSelect, scope }: { onSelect: (value: Record) => void, scope?: string }) => ( + AppSelector: ({ onSelect, scope }: { onSelect: (value: AppSelectorValue) => void, scope?: string }) => ( @@ -275,7 +276,7 @@ describe('ReasoningConfigForm', () => { auto: 0, value: { type: undefined, - value: { app_id: 'app-1', inputs: { topic: 'hello' } }, + value: { app_id: 'app-1', inputs: { topic: 'hello' }, files: [] }, }, }, })) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index f4b582f37e..e6af05065f 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -21,7 +21,7 @@ import Input from '@/app/components/base/input' import Tooltip from '@/app/components/base/tooltip' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FormInputBoolean from '@/app/components/workflow/nodes/_base/components/form-input-boolean' diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 2e95473bb2..38fa62a728 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -1,5 +1,6 @@ import type { ComponentProps } from 'react' import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector' import { fireEvent, screen, waitFor } from '@testing-library/react' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -45,8 +46,8 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ - default: ({ onSelect }: { onSelect: (value: string) => void }) => ( - + AppSelector: ({ onSelect }: { onSelect: (value: AppSelectorValue) => void }) => ( + ), })) @@ -341,7 +342,11 @@ describe('FormInputItem branches', () => { expect(app.onChange).toHaveBeenCalledWith({ field: { type: VarKindType.constant, - value: 'app-1', + value: { + app_id: 'app-1', + inputs: {}, + files: [], + }, }, }) diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 33f3f8fc7d..97c4c284cd 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from 'react' import CheckboxList from '@/app/components/base/checkbox-list' import Input from '@/app/components/base/input' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import { PluginCategoryEnum } from '@/app/components/plugins/types' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 01bfceb4a5..11d7d33295 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -467,7 +467,6 @@ describe('InputVarList', () => { await user.click(screen.getAllByText('app.appSelector.placeholder')[0]!) await user.click(screen.getAllByText('app.appSelector.placeholder')[1]!) await user.click(screen.getByTitle('Weather Assistant (app-1)')) - await user.type(screen.getByPlaceholderText('Topic'), 'weather') expect(onChange).toHaveBeenNthCalledWith(1, { assistant: { @@ -479,6 +478,10 @@ describe('InputVarList', () => { credential_id: 'credential-1', }, }) + + await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' })) + await user.type(screen.getByPlaceholderText('Topic'), 'weather') + expect(onChange).toHaveBeenLastCalledWith({ assistant: { app_id: 'app-1', diff --git a/web/app/components/workflow/nodes/tool/components/input-var-list.tsx b/web/app/components/workflow/nodes/tool/components/input-var-list.tsx index f24b0cfef3..6765c92033 100644 --- a/web/app/components/workflow/nodes/tool/components/input-var-list.tsx +++ b/web/app/components/workflow/nodes/tool/components/input-var-list.tsx @@ -12,7 +12,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' +import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx index f19cb2a181..933f55683b 100644 --- a/web/features/tag-management/__tests__/tag-filter.spec.tsx +++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx @@ -103,6 +103,19 @@ describe('TagFilter', () => { expect(onChange).toHaveBeenCalledWith(['tag-1']) }) + it('should select the highlighted tag with keyboard navigation', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + + await user.click(screen.getByText(i18n.placeholder)) + await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Back') + await user.keyboard('{ArrowDown}') + await user.keyboard('{Enter}') + + expect(onChange).toHaveBeenCalledWith(['tag-2']) + }) + it('should call onChange to deselect when an already-selected tag is clicked', async () => { const user = userEvent.setup() const onChange = vi.fn() diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index c1419998d1..4a976e8781 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -134,6 +134,22 @@ describe('TagSelector', () => { }) }) + it('selects the highlighted tag with keyboard navigation and applies it on close', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByRole('combobox', { name: /Frontend/i }) + await user.click(trigger) + await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'Back') + await user.keyboard('{ArrowDown}') + await user.keyboard('{Enter}') + await user.click(trigger) + + await waitFor(() => { + expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app') + }) + }) + it('applies removed tags only when the popup closes', async () => { const user = userEvent.setup() render() diff --git a/web/features/tag-management/components/tag-panel.tsx b/web/features/tag-management/components/tag-panel.tsx index 5576b6cbc1..d112dd9ceb 100644 --- a/web/features/tag-management/components/tag-panel.tsx +++ b/web/features/tag-management/components/tag-panel.tsx @@ -53,13 +53,12 @@ export const TagPanel = ({
{filteredItems.length > 0 && ( - {(tag: TagComboboxItem, index) => { + {(tag: TagComboboxItem) => { if (isCreateTagOption(tag)) { return ( @@ -76,7 +75,7 @@ export const TagPanel = ({ } return ( - + {tag.name} diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index f37f18f326..a20516ed95 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -107,7 +107,7 @@ export const TagSelector = ({ } if (inputValue && nextItems.every(tag => tag.name !== inputValue)) { - nextItems.unshift({ + nextItems.push({ id: `__create_tag__:${inputValue}`, name: inputValue, type,