fix(web): unify tool plugin selection state (#41470)

This commit is contained in:
yyh 2026-08-31 01:24:49 +00:00 committed by GitHub
parent 17ae89ff22
commit 94dff4dddc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 173 additions and 80 deletions

View File

@ -32,13 +32,13 @@ vi.mock('../../hooks', () => ({
}),
}))
const mockCurrentPluginID = vi.fn((): string | undefined => undefined)
const mockSetCurrentPluginID = vi.fn()
const mockSelectedItem = vi.fn((): { type: 'plugin'; id: string } | undefined => undefined)
const mockSetSelectedItem = vi.fn()
vi.mock('../../plugin-page/context', () => ({
usePluginPageContext: (selector: (v: Record<string, unknown>) => unknown) => {
const context = {
currentPluginID: mockCurrentPluginID(),
setCurrentPluginID: mockSetCurrentPluginID,
selectedItem: mockSelectedItem(),
setSelectedItem: mockSetSelectedItem,
}
return selector(context)
},
@ -174,7 +174,7 @@ describe('PluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme.mockReturnValue('light')
mockCurrentPluginID.mockReturnValue(undefined)
mockSelectedItem.mockReturnValue(undefined)
mockEnableMarketplace.mockReturnValue(true)
mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0'))
mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '')
@ -588,7 +588,7 @@ describe('PluginItem', () => {
// ==================== User Interactions Tests ====================
describe('User Interactions', () => {
it('should call setCurrentPluginID when plugin is clicked', () => {
it('should select the plugin when its card is clicked', () => {
// Arrange
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
@ -598,12 +598,15 @@ describe('PluginItem', () => {
fireEvent.click(pluginContainer)
// Assert
expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id')
expect(mockSetSelectedItem).toHaveBeenCalledWith({
type: 'plugin',
id: 'test-plugin-id',
})
})
it('should highlight selected plugin', () => {
// Arrange
mockCurrentPluginID.mockReturnValue('test-plugin-id')
mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'test-plugin-id' })
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
// Act
@ -616,7 +619,7 @@ describe('PluginItem', () => {
it('should not highlight unselected plugin', () => {
// Arrange
mockCurrentPluginID.mockReturnValue('other-plugin-id')
mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'other-plugin-id' })
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
// Act
@ -638,8 +641,8 @@ describe('PluginItem', () => {
const actionArea = screen.getByTestId('plugin-action').parentElement
fireEvent.click(actionArea!)
// Assert - setCurrentPluginID should not be called
expect(mockSetCurrentPluginID).not.toHaveBeenCalled()
// Assert - selecting the plugin should not be triggered
expect(mockSetSelectedItem).not.toHaveBeenCalled()
})
it('should only reveal actions on card hover or focus', () => {

View File

@ -47,8 +47,10 @@ const PluginItem: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
const selectedPluginID = usePluginPageContext((v) =>
v.selectedItem?.type === 'plugin' ? v.selectedItem.id : undefined,
)
const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const { refreshPluginList } = useRefreshPluginList()
const {
@ -119,13 +121,13 @@ const PluginItem: FC<Props> = ({
<div
className={cn(
'group/plugin-item relative overflow-hidden rounded-xl border-[1.5px] border-background-section-burn p-1',
currentPluginID === plugin_id && 'border-components-option-card-option-selected-border',
selectedPluginID === plugin_id && 'border-components-option-card-option-selected-border',
source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
: 'bg-background-section-burn',
)}
onClick={() => {
setCurrentPluginID(plugin.plugin_id)
setSelectedItem({ type: 'plugin', id: plugin.plugin_id })
}}
>
<div

View File

@ -1,5 +1,6 @@
import type { ReactElement, ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
@ -33,15 +34,22 @@ const renderWithProviders = (
}
const Consumer = () => {
const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
const selectedItem = usePluginPageContext((v) => v.selectedItem)
const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const options = usePluginPageContext((v) => v.options)
return (
<div>
<output aria-label="Current plugin">{currentPluginID ?? 'none'}</output>
<output aria-label="Selected item">
{selectedItem ? `${selectedItem.type}:${selectedItem.id}` : 'none'}
</output>
<output aria-label="Available tabs">{options.length}</output>
<button onClick={() => setCurrentPluginID('plugin-1')}>select plugin</button>
<button onClick={() => setSelectedItem({ type: 'builtinTool', id: 'builtin-1' })}>
select builtin tool
</button>
<button onClick={() => setSelectedItem({ type: 'plugin', id: 'plugin-1' })}>
select plugin
</button>
</div>
)
}
@ -62,7 +70,9 @@ describe('PluginPageContextProvider', () => {
expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('1')
})
it('keeps the query-state tab and updates the current plugin id', () => {
it('keeps the query-state tab and replaces the selected item', async () => {
const user = userEvent.setup()
renderWithProviders(
<PluginPageContextProvider>
<Consumer />
@ -70,9 +80,17 @@ describe('PluginPageContextProvider', () => {
{ enableMarketplace: true, searchParams: '?tab=discover' },
)
fireEvent.click(screen.getByText('select plugin'))
await user.click(screen.getByRole('button', { name: 'select builtin tool' }))
expect(screen.getByRole('status', { name: 'Current plugin' })).toHaveTextContent('plugin-1')
expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent(
'builtinTool:builtin-1',
)
await user.click(screen.getByRole('button', { name: 'select plugin' }))
expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent(
'plugin:plugin-1',
)
expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('2')
})
})

View File

@ -1,6 +1,8 @@
import type { PluginDetail } from '../../types'
import type { PluginPageSelection } from '../context'
import type { Collection } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import {
getStepByStepTourTargetSelector,
@ -15,14 +17,18 @@ const mockState = vi.hoisted(() => ({
tags: [] as string[],
searchQuery: '',
},
currentPluginID: undefined as string | undefined,
selectedItem: undefined as PluginPageSelection | undefined,
}))
const mockContextSubscribers = vi.hoisted(() => new Set<() => void>())
const mockSystemFeatures = vi.hoisted(() => ({
enableMarketplace: true,
}))
const mockSetFilters = vi.fn()
const mockSetCurrentPluginID = vi.fn()
const mockSetSelectedItem = vi.fn((item?: PluginPageSelection) => {
mockState.selectedItem = item
mockContextSubscribers.forEach((subscriber) => subscriber())
})
const mockLoadNextPage = vi.fn()
const mockInvalidateInstalledPluginList = vi.fn()
const mockRemoveFilteredInstalledPluginPageOnUnmount = vi.fn()
@ -55,22 +61,40 @@ vi.mock('../../hooks', () => ({
}),
}))
vi.mock('../context', () => ({
usePluginPageContext: (
selector: (value: {
filters: typeof mockState.filters
setFilters: typeof mockSetFilters
currentPluginID: string | undefined
setCurrentPluginID: typeof mockSetCurrentPluginID
}) => unknown,
) =>
selector({
filters: mockState.filters,
setFilters: mockSetFilters,
currentPluginID: mockState.currentPluginID,
setCurrentPluginID: mockSetCurrentPluginID,
}),
}))
vi.mock('../context', async () => {
const { useSyncExternalStore } = await import('react')
return {
usePluginPageContext: (
selector: (value: {
filters: typeof mockState.filters
setFilters: typeof mockSetFilters
selectedItem: PluginPageSelection | undefined
setSelectedItem: typeof mockSetSelectedItem
}) => unknown,
) =>
useSyncExternalStore(
(subscriber) => {
mockContextSubscribers.add(subscriber)
return () => mockContextSubscribers.delete(subscriber)
},
() =>
selector({
filters: mockState.filters,
setFilters: mockSetFilters,
selectedItem: mockState.selectedItem,
setSelectedItem: mockSetSelectedItem,
}),
() =>
selector({
filters: mockState.filters,
setFilters: mockSetFilters,
selectedItem: mockState.selectedItem,
setSelectedItem: mockSetSelectedItem,
}),
),
}
})
vi.mock('../filter-management', () => ({
default: ({
@ -140,13 +164,19 @@ vi.mock('../list', () => ({
}) => (
<div data-testid="plugin-list">
{pluginList.map((plugin, index) => (
<div
<button
type="button"
key={plugin.plugin_id}
aria-pressed={
mockState.selectedItem?.type === 'plugin' &&
mockState.selectedItem.id === plugin.plugin_id
}
data-step-by-step-tour-target={index === 0 ? firstPluginTarget : undefined}
data-testid="plugin-list-item"
onClick={() => mockSetSelectedItem({ type: 'plugin', id: plugin.plugin_id })}
>
{plugin.plugin_id}
</div>
</button>
))}
{children}
</div>
@ -250,13 +280,14 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
detail?: PluginDetail
onHide: () => void
onUpdate: () => void
}) => (
<div data-testid="plugin-detail-panel">
<span>{detail?.plugin_id ?? 'none'}</span>
<button onClick={onHide}>hide detail</button>
<button onClick={onUpdate}>refresh detail</button>
</div>
),
}) =>
detail ? (
<div data-testid="plugin-detail-panel">
<span>{detail.plugin_id}</span>
<button onClick={onHide}>hide detail</button>
<button onClick={onUpdate}>refresh detail</button>
</div>
) : null,
}))
const createPlugin = (
@ -324,7 +355,7 @@ describe('PluginsPanel', () => {
},
)
mockState.filters = { categories: [], tags: [], searchQuery: '' }
mockState.currentPluginID = undefined
mockState.selectedItem = undefined
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
@ -544,6 +575,43 @@ describe('PluginsPanel', () => {
expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument()
})
it('replaces the builtin tool detail when an installed plugin is selected', async () => {
vi.useRealTimers()
const user = userEvent.setup()
mockPluginListWithLatestVersion.mockReturnValue([
createPlugin('tool-plugin', 'Tool Plugin', [], PluginCategoryEnum.tool),
])
mockUseInstalledPluginList.mockReturnValue({
data: {
plugins: [],
builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')],
},
isLoading: false,
isFetching: false,
isLastPage: true,
loadNextPage: mockLoadNextPage,
})
render(<PluginsPanel contentInset="compact" fixedCategory={PluginCategoryEnum.tool} />)
const builtinToolCard = screen.getByRole('button', { name: 'builtin-tool' })
const pluginCard = screen.getByRole('button', { name: 'tool-plugin' })
await user.click(builtinToolCard)
expect(builtinToolCard).toHaveAttribute('aria-pressed', 'true')
expect(pluginCard).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByTestId('builtin-tool-detail')).toHaveTextContent('builtin-tool')
expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument()
await user.click(pluginCard)
expect(pluginCard).toHaveAttribute('aria-pressed', 'true')
expect(builtinToolCard).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('tool-plugin')
expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument()
})
it('filters builtin tools with the tool integrations search query', () => {
mockState.filters.searchQuery = 'alpha'
mockUseInstalledPluginList.mockReturnValue({
@ -898,7 +966,7 @@ describe('PluginsPanel', () => {
})
it('renders the empty state and keeps the current plugin detail in sync', () => {
mockState.currentPluginID = 'beta-tool'
mockState.selectedItem = { type: 'plugin', id: 'beta-tool' }
mockState.filters.searchQuery = 'missing'
mockPluginListWithLatestVersion.mockReturnValue([createPlugin('beta-tool', 'Beta Tool')])
@ -907,10 +975,10 @@ describe('PluginsPanel', () => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool')
fireEvent.click(screen.getByText('hide detail'))
fireEvent.click(screen.getByText('refresh detail'))
fireEvent.click(screen.getByText('hide detail'))
expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
expect(mockSetSelectedItem).toHaveBeenCalledWith(undefined)
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
})
})

View File

@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
import type { PluginPageTab } from './context'
import type { PluginPageSelection, PluginPageTab } from './context'
import type { FilterState } from './filter-management'
import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringEnum, useQueryState } from 'nuqs'
@ -38,7 +38,7 @@ export const PluginPageContextProvider = ({
searchQuery: '',
},
)
const [currentPluginID, setCurrentPluginID] = useState<string | undefined>()
const [selectedItem, setSelectedItem] = useState<PluginPageSelection | undefined>()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -56,8 +56,8 @@ export const PluginPageContextProvider = ({
<PluginPageContext.Provider
value={{
containerRef,
currentPluginID,
setCurrentPluginID,
selectedItem,
setSelectedItem,
filters,
setFilters,
activeTab,

View File

@ -11,10 +11,14 @@ export type PluginPageTab =
| (typeof PLUGIN_PAGE_TABS_MAP)[keyof typeof PLUGIN_PAGE_TABS_MAP]
| (typeof PLUGIN_TYPE_SEARCH_MAP)[keyof typeof PLUGIN_TYPE_SEARCH_MAP]
export type PluginPageSelection =
| { type: 'builtinTool'; id: string }
| { type: 'plugin'; id: string }
type PluginPageContextValue = {
containerRef: RefObject<HTMLDivElement | null>
currentPluginID: string | undefined
setCurrentPluginID: (pluginID?: string) => void
selectedItem: PluginPageSelection | undefined
setSelectedItem: (item?: PluginPageSelection) => void
filters: FilterState
setFilters: (filter: FilterState) => void
activeTab: PluginPageTab
@ -26,8 +30,8 @@ const emptyContainerRef: RefObject<HTMLDivElement | null> = { current: null }
export const PluginPageContext = createContext<PluginPageContextValue>({
containerRef: emptyContainerRef,
currentPluginID: undefined,
setCurrentPluginID: noop,
selectedItem: undefined,
setSelectedItem: noop,
filters: {
categories: [],
tags: [],

View File

@ -43,8 +43,8 @@ type PluginsPanelResultsProps = {
isLastPage: boolean
keywords: string
loadNextPage: () => void
onSelectBuiltinTool: (id: string) => void
scrollAreaLabel?: string
setCurrentBuiltinToolID: (id: string) => void
showCategoryEmptyState: boolean
tagFilterValue: string[]
}
@ -71,8 +71,8 @@ const PluginsPanelResults = ({
isLastPage,
keywords,
loadNextPage,
onSelectBuiltinTool,
scrollAreaLabel,
setCurrentBuiltinToolID,
showCategoryEmptyState,
tagFilterValue,
}: PluginsPanelResultsProps) => {
@ -152,7 +152,7 @@ const PluginsPanelResults = ({
data-step-by-step-tour-target={
filteredList.length === 0 && index === 0 ? firstBuiltinToolTarget : undefined
}
onClick={() => setCurrentBuiltinToolID(collection.id)}
onClick={() => onSelectBuiltinTool(collection.id)}
>
<IntegrationsToolProviderCard
collection={collection}

View File

@ -6,7 +6,7 @@ import type { FilterState } from './filter-management'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useMemo, useRef, useState } from 'react'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { isSearchResultEmpty } from '@/app/components/base/search-input/search-state'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
@ -130,9 +130,8 @@ const PluginsPanel = ({
INTEGRATION_PLUGIN_PAGE_SIZE,
installedPluginFilters,
)
const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState<string | undefined>()
const selectedItem = usePluginPageContext((v) => v.selectedItem)
const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const containerRef = useRef<HTMLDivElement>(null)
const { run: handleFilterChange } = useDebounceFn(
@ -186,18 +185,17 @@ const PluginsPanel = ({
sourceCount: categoryList.length + builtinTools.length,
})
const currentPluginDetail = useMemo(() => {
const detail = pluginListWithLatestVersion.find(
(plugin) => plugin.plugin_id === currentPluginID,
)
return detail
}, [currentPluginID, pluginListWithLatestVersion])
const currentPluginID = selectedItem?.type === 'plugin' ? selectedItem.id : undefined
const currentBuiltinToolID = selectedItem?.type === 'builtinTool' ? selectedItem.id : undefined
const currentPluginDetail = useMemo(
() => pluginListWithLatestVersion.find((plugin) => plugin.plugin_id === currentPluginID),
[currentPluginID, pluginListWithLatestVersion],
)
const currentBuiltinTool = useMemo(() => {
return filteredBuiltinTools.find((collection) => collection.id === currentBuiltinToolID)
}, [currentBuiltinToolID, filteredBuiltinTools])
const handleHide = () => setCurrentPluginID(undefined)
const handleBuiltinToolHide = () => setCurrentBuiltinToolID(undefined)
const handleDetailHide = () => setSelectedItem(undefined)
const hasToolMarketplacePanel = enableMarketplace && isToolIntegrationPage
const categoryMarketplace =
enableMarketplace && hasEmbeddedMarketplace ? fixedCategory : undefined
@ -284,7 +282,7 @@ const PluginsPanel = ({
keywords={filters.searchQuery}
loadNextPage={loadNextPage}
scrollAreaLabel={scrollAreaLabel}
setCurrentBuiltinToolID={setCurrentBuiltinToolID}
onSelectBuiltinTool={(id) => setSelectedItem({ type: 'builtinTool', id })}
tagFilterValue={filters.tags}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
@ -327,14 +325,14 @@ const PluginsPanel = ({
onUpdate={() => {
invalidateInstalledPluginList(fixedCategory)
}}
onHide={handleHide}
onHide={handleDetailHide}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
/>
{currentBuiltinTool && !currentBuiltinTool.plugin_id && (
<ProviderDetail
collection={currentBuiltinTool}
onHide={handleBuiltinToolHide}
onHide={handleDetailHide}
onRefreshData={invalidateInstalledPluginList}
/>
)}