mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
refactor: migrate app selector to combobox (#35896)
This commit is contained in:
parent
7901ac9a97
commit
2ff50514c8
@ -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'
|
||||
|
||||
@ -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 },
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: [] },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user