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('')}
- />
+
+
+
+ {searchText && (
+
+ )}
+
- {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,