diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index dc148df534b..62c32c56270 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -138,6 +138,34 @@ describe('getPluginDetailLinkInMarketplace', () => { }) }) +describe('getTemplateDetailLinkInMarketplace', () => { + it('should return the local template detail link', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-1', + template_name: 'Legal Research Agent', + publisher_handle: 'dify', + publisher_unique_handle: 'dify-unique', + }), + ).toBe('/template/dify/Legal%20Research%20Agent?templateId=template-1') + }) + + it('should fall back to the unique publisher handle', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-2', + template_name: 'Inbox', + publisher_handle: '', + publisher_unique_handle: 'langgenius', + }), + ).toBe('/template/langgenius/Inbox?templateId=template-2') + }) +}) + describe('getMarketplaceListCondition', () => { it('should return category condition for tool', async () => { const { getMarketplaceListCondition } = await import('../utils') diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx index 5e400ed0e40..ba7d2be04dc 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx @@ -29,7 +29,6 @@ vi.mock('#i18n', async () => { 'marketplace.home.templates': 'Templates', 'marketplace.loadError': 'Failed to load. Please try again.', 'marketplace.noPluginFound': 'No integration found', - 'marketplace.viewMore': 'View more', 'newApp.noTemplateFound': 'No templates found', clearSearch: 'Clear search', loading: 'Loading', @@ -155,6 +154,7 @@ describe('EmbeddedMarketplaceSearch', () => { const pluginGroup = screen.getByRole('group', { name: 'Plugins' }) expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument() expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() expect(onUrlUpdate).not.toHaveBeenCalled() }) @@ -211,33 +211,6 @@ describe('EmbeddedMarketplaceSearch', () => { expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument() }) - it('filters the current catalog when the visitor asks to view more results', async () => { - mockPluginSearch.mockResolvedValue({ - data: { - plugins: [ - { - type: 'plugin', - org: 'langgenius', - name: 'google-search', - label: { en_US: 'Google Search' }, - brief: { en_US: 'Search the web from your workflow.' }, - category: 'tool', - }, - ], - total: 1, - }, - }) - const user = userEvent.setup() - const { onUrlUpdate } = renderSearch() - - await user.type(screen.getByRole('combobox'), 'google') - await user.click(await screen.findByRole('button', { name: 'View more' })) - - await waitFor(() => { - expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google') - }) - }) - it('filters the current catalog when Enter is pressed instead of opening a result', async () => { mockPluginSearch.mockResolvedValue({ data: { diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx index 89103801459..1446ce8b2ad 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -15,6 +15,10 @@ const { mockTemplateSearch } = vi.hoisted(() => ({ mockTemplateSearch: vi.fn(), })) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + vi.mock('ahooks', async (importOriginal) => { const original = await importOriginal() @@ -37,7 +41,6 @@ vi.mock('react-i18next', async (importOriginal) => { 'marketplace.home.plugins': 'Plugins', 'marketplace.home.templates': 'Templates', 'marketplace.noPluginFound': 'No integration found', - 'marketplace.viewMore': 'View more', 'newApp.noTemplateFound': 'No templates found', }), } @@ -209,6 +212,8 @@ describe('Marketplace search autocomplete layout', () => { await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') await expect.element(screen.getByText('Legal Research Agent')).toBeVisible() + const input = screen.getByRole('combobox', { name: 'Search templates' }).element() + const searchBox = input.parentElement! const list = screen.getByRole('listbox').element() const panel = list.parentElement! const templateGroup = screen.getByRole('group', { name: 'Templates' }).element() @@ -221,7 +226,7 @@ describe('Marketplace search autocomplete layout', () => { const statusRoots = screen.getByRole('status').all() const trailingStatus = statusRoots.at(-1)!.element() - expect(panelStyle.width).toBe('472px') + expect(panel.getBoundingClientRect().width).toBeCloseTo(searchBox.getBoundingClientRect().width) expect(panelStyle.paddingTop).toBe('0px') expect(panelStyle.paddingRight).toBe('0px') expect(panelStyle.paddingBottom).toBe('0px') diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx index a608d98aade..6954489af47 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -10,11 +10,12 @@ import { MarketplaceSearchForm, } from '../marketplace-search-autocomplete' -const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ +const { debounceState, mockPluginSearch, mockPush, mockTemplateSearch } = vi.hoisted(() => ({ // Most tests bypass the debounce for simplicity; the debounce-window test // flips this on to exercise the real 300ms lag. debounceState: { useRealDebounce: false }, mockPluginSearch: vi.fn(), + mockPush: vi.fn(), mockTemplateSearch: vi.fn(), })) @@ -38,11 +39,14 @@ vi.mock('react-i18next', async () => { 'marketplace.home.plugins': 'Plugins', 'marketplace.home.templates': 'Templates', 'marketplace.noPluginFound': 'No integration found', - 'marketplace.viewMore': 'View more', 'newApp.noTemplateFound': 'No templates found', }) }) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + vi.mock('@/service/client', () => ({ marketplaceQuery: { searchAdvanced: { @@ -69,6 +73,7 @@ function Wrapper({ children }: { children: ReactNode }) { describe('MarketplaceSearchAutocomplete', () => { beforeEach(() => { vi.clearAllMocks() + mockPush.mockReset() debounceState.useRealDebounce = false queryClient = new QueryClient({ defaultOptions: { @@ -131,6 +136,12 @@ describe('MarketplaceSearchAutocomplete', () => { expect(screen.getAllByRole('status').length).toBeGreaterThan(0) expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument() + + await user.click(screen.getByText('Legal Research Agent')) + expect(mockPush).toHaveBeenCalledWith( + '/template/dify/Legal%20Research%20Agent?templateId=template-1', + ) + expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge') expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q') expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text') @@ -189,7 +200,7 @@ describe('MarketplaceSearchAutocomplete', () => { expect(mockTemplateSearch).not.toHaveBeenCalled() }) - it('groups mixed suggestions and submits the complete search from the popup', async () => { + it('groups mixed suggestions and opens the selected result instead of viewing more', async () => { mockTemplateSearch.mockResolvedValue({ data: { templates: [ @@ -247,10 +258,12 @@ describe('MarketplaceSearchAutocomplete', () => { const pluginGroup = screen.getByRole('group', { name: 'Plugins' }) expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument() expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'View more' })) + await user.click(screen.getByText('Google Search')) - expect(handleSubmit).toHaveBeenCalledOnce() + expect(mockPush).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() }) it('submits the typed query on Enter without selecting a hovered suggestion', async () => { @@ -313,7 +326,7 @@ describe('MarketplaceSearchAutocomplete', () => { expect(screen.getByRole('combobox')).toHaveValue('search') }) - it('submits the route search form when a suggestion is chosen', async () => { + it('opens plugin detail when a suggestion is chosen', async () => { mockPluginSearch.mockResolvedValue({ data: { plugins: [ @@ -350,7 +363,8 @@ describe('MarketplaceSearchAutocomplete', () => { await user.type(screen.getByRole('combobox'), 'google') await user.click(await screen.findByText('Google Search')) - expect(handleSubmit).toHaveBeenCalledOnce() + expect(mockPush).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() }) it('selects a suggestion without submitting when the parent handles the result', async () => { @@ -445,7 +459,8 @@ describe('MarketplaceSearchAutocomplete', () => { expect(await screen.findByText('Google Search')).toBeInTheDocument() await user.keyboard('{ArrowDown}{Enter}') - expect(handleSubmit).toHaveBeenCalledOnce() + expect(mockPush).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() }) it('hands the selected plugin back to a creator-profile owner without submitting', async () => { diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx index f7375f1717f..e8d78dac2ad 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -26,9 +26,14 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from '#i18n' import { MARKETPLACE_API_PREFIX } from '@/config' import { renderI18nObject } from '@/i18n-config/index' +import { useRouter } from '@/next/navigation' import { marketplaceQuery } from '@/service/client' import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track' -import { getPluginIconInMarketplace } from '../utils' +import { + getPluginDetailLinkInMarketplace, + getPluginIconInMarketplace, + getTemplateDetailLinkInMarketplace, +} from '../utils' type MarketplaceSearchScope = 'all' | 'plugins' | 'templates' @@ -93,17 +98,11 @@ const toPluginSuggestion = (plugin: MarketplacePlugin, locale: string): Marketpl selection: { kind: 'plugin', plugin }, }) -type MarketplaceSuggestionListProps = { - onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void - onValueChange: (value: string) => void - setIsOpen: (isOpen: boolean) => void -} - function MarketplaceSuggestionList({ - onSuggestionSelect, - onValueChange, - setIsOpen, -}: MarketplaceSuggestionListProps) { + onSelect, +}: { + onSelect: (selection: MarketplaceSearchSelection) => void +}) { const groups = useAutocompleteFilteredItems() return ( @@ -120,17 +119,7 @@ function MarketplaceSuggestionList({ key={item.id} value={item} className="mx-0 items-start gap-1 rounded-lg py-1 pr-1 pl-3 hover:bg-state-base-hover data-highlighted:bg-state-base-hover" - onClick={ - onSuggestionSelect - ? () => { - onSuggestionSelect(item.selection) - queueMicrotask(() => { - onValueChange('') - setIsOpen(false) - }) - } - : undefined - } + onClick={() => onSelect(item.selection)} > {item.iconUrl ? ( @@ -194,6 +183,7 @@ export function MarketplaceSearchAutocomplete({ value, }: MarketplaceSearchAutocompleteProps) { const { t } = useTranslation() + const router = useRouter() const [isOpen, setIsOpen] = useState(false) const searchRootRef = useRef(null) const resultsPanelRef = useRef(null) @@ -203,6 +193,23 @@ export function MarketplaceSearchAutocomplete({ const form = searchRootRef.current?.closest('form') if (form instanceof HTMLFormElement) form.requestSubmit() } + const openSuggestion = (selection: MarketplaceSearchSelection) => { + if (onSuggestionSelect) { + onSuggestionSelect(selection) + queueMicrotask(() => { + onValueChange('') + setIsOpen(false) + }) + return + } + + const href = + selection.kind === 'plugin' + ? getPluginDetailLinkInMarketplace(selection.plugin) + : getTemplateDetailLinkInMarketplace(selection.template) + setIsOpen(false) + router.push(href) + } const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) const hasQuery = Boolean(debouncedSearch) const searchesPlugins = scope === 'all' || scope === 'plugins' @@ -327,7 +334,7 @@ export function MarketplaceSearchAutocomplete({ }} open={isPopupOpen} openOnInputClick - submitOnItemClick={Boolean(inputName) && !onSuggestionSelect} + submitOnItemClick={false} value={value} onItemHighlighted={(item, details) => { keyboardHighlightedRef.current = Boolean(item) && details.reason === 'keyboard' @@ -360,42 +367,19 @@ export function MarketplaceSearchAutocomplete({ )} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index c375654c8d4..6d3dfeea1a6 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -70,6 +70,18 @@ export const getPluginDetailLinkInMarketplace = ( return `/plugin/${org}/${name}` } +export const getTemplateDetailLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, +) => { + const publisher = template.publisher_handle || template.publisher_unique_handle || 'template' + const search = new URLSearchParams({ templateId: template.id }) + + return `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}?${search.toString()}` +} + export const getTemplateLinkInMarketplace = ( template: Pick< MarketplaceTemplate,