refactor: migrate app selector to combobox (#35896)

This commit is contained in:
yyh 2026-05-08 09:23:32 +08:00 committed by GitHub
parent 7901ac9a97
commit 2ff50514c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 460 additions and 3152 deletions

View File

@ -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'

View File

@ -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 }) => (
<button type="button" onClick={() => onSelect({ id: 'app-1' })}>Select App</button>
AppSelector: ({ onSelect }: { onSelect: (item: AppSelectorValue) => void }) => (
<button type="button" onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}>Select App</button>
),
}))
@ -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 },
}))
})

View File

@ -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: () => <div data-testid="app-icon" />,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
onClear,
}: {
value: string
onChange: (e: { target: { value: string } }) => void
onClear?: () => void
}) => (
<div>
<input
data-testid="search-input"
value={value}
onChange={e => onChange({ target: { value: e.target.value } })}
/>
<button data-testid="clear-input" onClick={onClear}>Clear</button>
</div>
),
}))
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
children,
open,
}: {
children: ReactNode
open: boolean
}) => (
<div data-testid="portal" data-open={open}>
{children}
</div>
),
PopoverTrigger: ({
children,
render,
onClick,
}: {
children: ReactNode
render?: ReactNode
onClick?: () => void
}) => (
<button data-testid="picker-trigger" onClick={onClick}>
{render ?? children}
</button>
),
PopoverContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
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(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
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(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
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(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
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()
})
})

View File

@ -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 }) => <div data-testid="app-icon" data-size={size} />,
}))
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(<AppTrigger open={false} />)
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(<AppTrigger open={false} appDetail={appDetail as never} />)
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('should render when open', () => {
const { container } = render(<AppTrigger open={true} />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@ -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<Props> = ({
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 (
<ComboboxItem
key={app.id}
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span title={`${app.name} (${app.id})`} className="min-w-0 grow truncate system-sm-medium text-components-input-text-filled">
<span className="mr-1">{app.name}</span>
<span className="text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{getAppTypeLabel(app)}</span>
</ComboboxItem>
)
}
export function AppPicker({
disabled,
trigger,
placement = 'right-start',
@ -49,186 +107,91 @@ const AppPicker: FC<Props> = ({
onLoadMore,
searchText,
onSearchChange,
}) => {
}: AppPickerProps) {
const { t } = useTranslation()
const observerTargetRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadingRef = useRef(false)
const loadingResetTimerIdRef = useRef<number | undefined>(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<HTMLElement>) => {
event.preventDefault()
if (disabled || isShow)
return
onShowChange(true)
}, [disabled, isShow, onShowChange])
onSelect(app)
onShowChange(false)
}, [onSelect, onShowChange])
return (
<Popover
<Combobox<App>
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}
>
<PopoverTrigger
render={<div>{trigger}</div>}
onClick={handleTriggerClick}
/>
<PopoverContent
<ComboboxTrigger
aria-label={t('appSelector.label', { ns: 'app' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
{trigger}
</ComboboxTrigger>
<ComboboxContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
sideOffset={offset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={searchText}
onChange={e => onSearchChange(e.target.value)}
onClear={() => onSearchChange('')}
/>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('appSelector.placeholder', { ns: 'app' })}
placeholder={t('appSelector.placeholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
{searchText && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => onSearchChange('')}
>
<span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" />
</button>
)}
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{apps.map(app => (
<div
key={app.id}
className="flex cursor-pointer items-center gap-3 rounded-lg py-1 pr-3 pl-2 hover:bg-state-base-hover"
onClick={() => onSelect(app)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<div title={`${app.name} (${app.id})`} className="grow system-sm-medium text-components-input-text-filled">
<span className="mr-1">{app.name}</span>
<span className="text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</div>
<div className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{getAppType(app)}</div>
</div>
))}
<div ref={observerTargetRef} className="h-4 w-full">
{isLoading && (
<div className="flex justify-center py-2">
<div className="text-sm text-gray-500">{t('loading', { ns: 'common' })}</div>
</div>
{isLoading && (
<ComboboxStatus>
{t('loading', { ns: 'common' })}
</ComboboxStatus>
)}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<AppPickerOption key={app.id} app={app} />
)}
</div>
</ComboboxList>
<ComboboxEmpty>
{t('noData', { ns: 'common' })}
</ComboboxEmpty>
{hasMore && (
<div className="flex justify-center px-3 py-2">
<Button
size="small"
disabled={isLoading}
onClick={() => onLoadMore()}
>
{isLoading ? t('loading', { ns: 'common' }) : t('common.loadMore', { ns: 'workflow' })}
</Button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</ComboboxContent>
</Combobox>
)
}
export default React.memo(AppPicker)

View File

@ -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 (
<div className={cn(
'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
appDetail && 'py-1.5 pl-1.5',
)}
<span
className={cn(
'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
appDetail && 'py-1.5 pl-1.5',
)}
>
{appDetail && (
<AppIcon
className="mr-2"
className="mr-2 shrink-0"
size="xs"
iconType={appDetail.icon_type}
icon={appDetail.icon}
@ -35,15 +34,24 @@ const AppTrigger = ({
imageUrl={appDetail.icon_url}
/>
)}
{appDetail && (
<div title={appDetail.name} className="grow system-sm-medium text-components-input-text-filled">{appDetail.name}</div>
)}
{!appDetail && (
<div className="grow truncate system-sm-regular text-components-input-text-placeholder">{t('appSelector.placeholder', { ns: 'app' })}</div>
)}
<RiArrowDownSLine className={cn('ml-0.5 h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
{appDetail
? (
<span title={appDetail.name} className="min-w-0 grow truncate system-sm-medium text-components-input-text-filled">
{appDetail.name}
</span>
)
: (
<span className="min-w-0 grow truncate system-sm-regular text-components-input-text-placeholder">
{t('appSelector.placeholder', { ns: 'app' })}
</span>
)}
<span
className={cn(
'ml-0.5 i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
export default AppTrigger

View File

@ -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<string, unknown>
files?: unknown[]
}
export type AppSelectorValue = {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
}
type AppSelectorProps = {
value?: AppSelectorValue
scope?: string
disabled?: boolean
placement?: Placement
offset?: OffsetOptions
onSelect: (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
}) => void
supportAddCustomTool?: boolean
offset?: number
onSelect: (app: AppSelectorValue) => void
}
const AppSelector: FC<Props> = ({
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<AppListQuery>(() => ({
const appListQuery = useMemo(() => ({
page: 1,
limit: PAGE_SIZE,
name: searchText,
@ -80,150 +72,105 @@ const AppSelector: FC<Props> = ({
})
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<HTMLElement>) => {
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<string, unknown>) => {
const handleFormChange = useCallback((inputs: Record<string, unknown>) => {
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 (
<>
<Popover
open={isShow}
onOpenChange={setIsShow}
<Popover
open={isShow}
onOpenChange={setIsShow}
>
<PopoverTrigger
aria-label={t('appSelector.label', { ns: 'app' })}
disabled={disabled}
render={<button type="button" className="block w-full border-0 bg-transparent p-0 text-left" />}
>
<PopoverTrigger
render={(
<div className="w-full">
<AppTrigger
open={isShow}
appDetail={currentAppInfo}
/>
</div>
)}
onClick={handleTriggerClick}
<AppTrigger
open={isShow}
appDetail={currentAppInfo}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="flex flex-col gap-1 px-4 py-3">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
<AppPicker
placement="bottom"
offset={offset}
trigger={(
<AppTrigger
open={isShowChooseApp}
appDetail={currentAppInfo}
/>
)}
isShow={isShowChooseApp}
onShowChange={setIsShowChooseApp}
disabled={false}
onSelect={handleSelectApp}
scope={scope || 'all'}
apps={appsForPicker}
isLoading={isLoading || isFetchingNextPage}
hasMore={hasMore}
onLoadMore={handleLoadMore}
searchText={searchText}
onSearchChange={setSearchText}
/>
</div>
{/* app inputs config panel */}
{currentAppInfo && (
<AppInputsPanel
value={formattedValue}
appDetail={currentAppInfo}
onFormChange={handleFormChange}
/>
)}
</PopoverTrigger>
<PopoverContent
placement={placement}
sideOffset={offset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="flex flex-col gap-1 px-4 py-3">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
<AppPicker
placement="bottom"
offset={offset}
trigger={(
<AppTrigger
open={isShowChooseApp}
appDetail={currentAppInfo}
/>
)}
isShow={isShowChooseApp}
onShowChange={setIsShowChooseApp}
disabled={false}
onSelect={handleSelectApp}
apps={displayedApps}
isLoading={isLoading || isFetchingNextPage}
hasMore={hasMore}
onLoadMore={() => {
void fetchNextPage()
}}
searchText={searchText}
onSearchChange={setSearchText}
/>
</div>
</PopoverContent>
</Popover>
</>
{currentAppInfo && (
<AppInputsPanel
value={formattedValue}
appDetail={currentAppInfo}
onFormChange={handleFormChange}
/>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default React.memo(AppSelector)

View File

@ -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<string, unknown>) => void, scope?: string }) => (
AppSelector: ({ onSelect, scope }: { onSelect: (value: AppSelectorValue) => void, scope?: string }) => (
<button
data-testid="app-selector"
data-scope={scope}
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' } })}
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' }, files: [] })}
>
Select App
</button>
@ -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: [] },
},
},
}))

View File

@ -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'

View File

@ -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 }) => (
<button onClick={() => onSelect('app-1')}>app-selector</button>
AppSelector: ({ onSelect }: { onSelect: (value: AppSelectorValue) => void }) => (
<button onClick={() => onSelect({ app_id: 'app-1', inputs: {}, files: [] })}>app-selector</button>
),
}))
@ -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: [],
},
},
})

View File

@ -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'

View File

@ -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',

View File

@ -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'

View File

@ -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(<TagFilter {...defaultProps} onChange={onChange} />)
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()

View File

@ -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(<TagSelector {...defaultProps} />)
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(<TagSelector {...defaultProps} />)

View File

@ -53,13 +53,12 @@ export const TagPanel = ({
</div>
{filteredItems.length > 0 && (
<ComboboxList className="max-h-58">
{(tag: TagComboboxItem, index) => {
{(tag: TagComboboxItem) => {
if (isCreateTagOption(tag)) {
return (
<Fragment key={tag.id}>
<ComboboxItem
value={tag}
index={index}
data-testid="create-tag-option"
>
<ComboboxItemText className="flex items-center gap-x-1 px-0">
@ -76,7 +75,7 @@ export const TagPanel = ({
}
return (
<ComboboxItem key={tag.id} value={tag} index={index}>
<ComboboxItem key={tag.id} value={tag}>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>

View File

@ -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,