diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx index 95215e021de..456028c6ec2 100644 --- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx @@ -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) => 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) => 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', () => { diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 05d0ed90da5..edd886574d0 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -47,8 +47,10 @@ const PluginItem: FC = ({ }) => { 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 = ({
{ - setCurrentPluginID(plugin.plugin_id) + setSelectedItem({ type: 'plugin', id: plugin.plugin_id }) }} >
{ - 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 (
- {currentPluginID ?? 'none'} + + {selectedItem ? `${selectedItem.type}:${selectedItem.id}` : 'none'} + {options.length} - + +
) } @@ -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( @@ -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') }) }) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index 9f8a18453a1..c4b14947809 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -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', () => ({ }) => (
{pluginList.map((plugin, index) => ( -
mockSetSelectedItem({ type: 'plugin', id: plugin.plugin_id })} > {plugin.plugin_id} -
+ ))} {children}
@@ -250,13 +280,14 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ detail?: PluginDetail onHide: () => void onUpdate: () => void - }) => ( -
- {detail?.plugin_id ?? 'none'} - - -
- ), + }) => + detail ? ( +
+ {detail.plugin_id} + + +
+ ) : 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() + + 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() }) }) diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx index 457ca4386a8..2985e8c68ea 100644 --- a/web/app/components/plugins/plugin-page/context-provider.tsx +++ b/web/app/components/plugins/plugin-page/context-provider.tsx @@ -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() + const [selectedItem, setSelectedItem] = useState() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -56,8 +56,8 @@ export const PluginPageContextProvider = ({ - 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 = { current: null } export const PluginPageContext = createContext({ containerRef: emptyContainerRef, - currentPluginID: undefined, - setCurrentPluginID: noop, + selectedItem: undefined, + setSelectedItem: noop, filters: { categories: [], tags: [], diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx index 1031fbf45e4..3d0f46513f9 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx @@ -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)} > v.currentPluginID) - const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID) - const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState() + const selectedItem = usePluginPageContext((v) => v.selectedItem) + const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem) const containerRef = useRef(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 && ( )}