From ab3bb9dd6c33a7ea9470ef2479ff9d628b78e7ac Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 5 Aug 2026 17:43:08 +0800 Subject: [PATCH] fix: restore marketplace search interactions --- .../marketplace-live-search.spec.tsx | 62 +++++ .../marketplace-search-autocomplete.spec.tsx | 171 ++++++++++++ .../plugins/marketplace/home/home-search.tsx | 10 +- .../home/marketplace-live-search.tsx | 74 +++++ .../home/marketplace-plugin-search.tsx | 28 ++ .../home/marketplace-search-autocomplete.tsx | 261 ++++++++++++++++++ .../plugins/marketplace/templates/index.tsx | 33 +-- 7 files changed, 604 insertions(+), 35 deletions(-) create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/marketplace-live-search.tsx create mode 100644 web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx create mode 100644 web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx new file mode 100644 index 00000000000..d7c430c6fd3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx @@ -0,0 +1,62 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MarketplaceLiveSearch from '../marketplace-live-search' + +const { mockReplace } = vi.hoisted(() => ({ + mockReplace: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + useDebounce: (value: T) => value, + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})) + +describe('MarketplaceLiveSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('updates the active tab result route while the user types', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.type(screen.getByRole('searchbox'), 'legal') + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', { + scroll: false, + }) + }) + }) + + it('clears the query without leaving the active plugin tab', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.clear(screen.getByRole('searchbox')) + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false }) + }) + }) +}) 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 new file mode 100644 index 00000000000..24fce66e5d6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -0,0 +1,171 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../marketplace-search-autocomplete' + +const { mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + useDebounce: (value: T) => value, + } +}) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'gotoAnything.searching': 'Searching...', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + })[key] ?? key, + }), +})) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => mockPluginSearch(input), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return {children} +} + +describe('MarketplaceSearchAutocomplete', () => { + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, + }) + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('shows template suggestions and keeps the route search form contract', async () => { + let resolveTemplateSearch!: (value: unknown) => void + const templateSearchPromise = new Promise((resolve) => { + resolveTemplateSearch = resolve + }) + mockTemplateSearch.mockReturnValue(templateSearchPromise) + const templateSearchResponse = { + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + } + const user = userEvent.setup() + + const { container } = render( + , + { wrapper: Wrapper }, + ) + + await user.type(screen.getByRole('combobox'), 'legal') + expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument() + resolveTemplateSearch(templateSearchResponse) + + expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument() + expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument() + expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge') + expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q') + expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US') + expect(mockPluginSearch).not.toHaveBeenCalled() + }) + + it('shows plugin suggestions while preserving the controlled search owner', 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 onValueChange = vi.fn() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + { + onValueChange(nextValue) + setValue(nextValue) + }} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + + expect(await screen.findByText('Google Search')).toBeInTheDocument() + expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument() + expect(onValueChange).toHaveBeenLastCalledWith('google') + expect(mockTemplateSearch).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx index e161632a430..eb9ca58e5b4 100644 --- a/web/app/components/plugins/marketplace/home/home-search.tsx +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -4,8 +4,8 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { useEffect, useRef } from 'react' import { useTranslation } from '#i18n' -import SearchBoxWrapper from '@/app/components/plugins/marketplace/search-box/search-box-wrapper' import styles from './home-sticky.module.css' +import MarketplacePluginSearch from './marketplace-plugin-search' const HomeSearch = ({ children }: { children?: ReactNode }) => { const searchRef = useRef(null) @@ -32,14 +32,8 @@ const HomeSearch = ({ children }: { children?: ReactNode }) => { >
{children ?? ( - $['marketplace.home.searchPlaceholder'])} - showTags={false} - usedInMarketplace={false} /> )}
diff --git a/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx new file mode 100644 index 00000000000..ddb80c44f74 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx @@ -0,0 +1,74 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useDebounce } from 'ahooks' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useRouter } from '@/next/navigation' + +type MarketplaceLiveSearchProps = { + action: string + className?: string + language?: string + placeholder: string + query: string +} + +export default function MarketplaceLiveSearch({ + action, + className, + language, + placeholder, + query, +}: MarketplaceLiveSearchProps) { + const router = useRouter() + const [value, setValue] = useState(query) + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const routedSearchRef = useRef(query.trim()) + const navigate = useCallback( + (nextQuery: string) => { + const searchParams = new URLSearchParams() + if (nextQuery) searchParams.set('q', nextQuery) + if (language) searchParams.set('language', language) + const queryString = searchParams.toString() + + router.replace(`${action}${queryString ? `?${queryString}` : ''}`, { scroll: false }) + }, + [action, language, router], + ) + + useEffect(() => { + if (debouncedSearch === routedSearchRef.current) return + + routedSearchRef.current = debouncedSearch + navigate(debouncedSearch) + }, [debouncedSearch, navigate]) + + return ( +
{ + event.preventDefault() + const nextQuery = value.trim() + routedSearchRef.current = nextQuery + navigate(nextQuery) + }} + > + + setValue(event.target.value)} + placeholder={placeholder} + className="h-9 w-full rounded-[10px] border-[0.5px] border-components-input-border-active bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary focus:border-state-accent-solid" + /> + {language && } + + ) +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx new file mode 100644 index 00000000000..8ea8619594b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useLocale } from '@/context/i18n' +import { useActivePluginType, useSearchPluginText } from '../atoms' +import { MarketplaceSearchAutocomplete } from './marketplace-search-autocomplete' + +type MarketplacePluginSearchProps = { + placeholder: string +} + +export default function MarketplacePluginSearch({ placeholder }: MarketplacePluginSearchProps) { + const locale = useLocale() + const [category] = useActivePluginType() + const [value, setValue] = useSearchPluginText() + + return ( + { + void setValue(nextValue) + }} + placeholder={placeholder} + scope="plugins" + value={value} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx new file mode 100644 index 00000000000..785c0cb3bf0 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -0,0 +1,261 @@ +'use client' + +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import { + Autocomplete, + AutocompleteClear, + AutocompleteContent, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteInputGroup, + AutocompleteItem, + AutocompleteItemIndicator, + AutocompleteItemText, + AutocompleteList, + AutocompleteStatus, +} from '@langgenius/dify-ui/autocomplete' +import { cn } from '@langgenius/dify-ui/cn' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { renderI18nObject } from '@/i18n-config/index' +import { marketplaceQuery } from '@/service/client' + +export type MarketplaceSearchScope = 'all' | 'plugins' | 'templates' + +type MarketplaceSuggestion = { + description: string + id: string + kind: 'plugin' | 'template' + label: string + meta: string +} + +type MarketplaceSearchAutocompleteProps = { + category?: string + inputName?: string + locale: string + onValueChange: (value: string) => void + placeholder: string + scope: MarketplaceSearchScope + value: string +} + +const getPluginText = ( + value: MarketplacePlugin['brief'] | MarketplacePlugin['label'], + locale: string, +) => { + if (typeof value === 'string') return value + return renderI18nObject((value ?? {}) as Record, locale) +} + +const toTemplateSuggestion = (template: MarketplaceTemplate): MarketplaceSuggestion => ({ + description: template.overview, + id: `template:${template.id}`, + kind: 'template', + label: template.template_name, + meta: template.publisher_handle || template.publisher_unique_handle || '', +}) + +const toPluginSuggestion = (plugin: MarketplacePlugin, locale: string): MarketplaceSuggestion => ({ + description: getPluginText(plugin.brief, locale), + id: `plugin:${plugin.org}/${plugin.name}`, + kind: 'plugin', + label: getPluginText(plugin.label, locale) || plugin.name, + meta: plugin.org, +}) + +export function MarketplaceSearchAutocomplete({ + category = 'all', + inputName, + locale, + onValueChange, + placeholder, + scope, + value, +}: MarketplaceSearchAutocompleteProps) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const translate = t as (key: string, options?: Record) => string + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const hasQuery = Boolean(debouncedSearch) + const showDropdown = isOpen && hasQuery + const searchesPlugins = scope === 'all' || scope === 'plugins' + const searchesTemplates = scope === 'all' || scope === 'templates' + const isBundleSearch = category === 'bundle' + const pluginQuery = useQuery({ + ...marketplaceQuery.searchAdvanced.queryOptions({ + input: { + params: { kind: isBundleSearch ? 'bundles' : 'plugins' }, + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'install_count', + sort_order: 'DESC', + category: category !== 'all' && !isBundleSearch ? category : '', + }, + }, + retry: false, + }), + enabled: hasQuery && searchesPlugins, + placeholderData: keepPreviousData, + staleTime: 60_000, + }) + const templateQuery = useQuery({ + ...marketplaceQuery.templateSearch.queryOptions({ + input: { + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'usage_count', + sort_order: 'DESC', + ...(category !== 'all' ? { categories: [category] } : {}), + }, + }, + retry: false, + }), + enabled: hasQuery && searchesTemplates, + placeholderData: keepPreviousData, + staleTime: 60_000, + }) + const pluginSuggestions = searchesPlugins + ? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) => + toPluginSuggestion(plugin, locale), + ) + : [] + const templateSuggestions = searchesTemplates + ? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion) + : [] + const suggestions = [...templateSuggestions, ...pluginSuggestions] + const isSearching = pluginQuery.isFetching || templateQuery.isFetching + const emptyText = + scope === 'templates' + ? translate('newApp.noTemplateFound', { ns: 'app' }) + : translate('marketplace.noPluginFound', { ns: 'plugin' }) + + return ( + item.label} + items={suggestions} + mode="list" + name={inputName} + onOpenChange={setIsOpen} + onValueChange={(nextValue) => { + onValueChange(nextValue) + setIsOpen(Boolean(nextValue.trim())) + }} + open={showDropdown} + openOnInputClick + submitOnItemClick={Boolean(inputName)} + value={value} + > + + + + {!!value && ( + + )} + + + {isSearching && suggestions.length === 0 && ( + + {translate('gotoAnything.searching', { ns: 'app' })} + + )} + > + {(item) => ( + + + + + {item.label} + + {!!item.description && ( + + {item.description} + + )} + {!!item.meta && ( + + {item.meta} + + )} + + + + )} + + {!isSearching && {emptyText}} + + + ) +} + +type MarketplaceSearchFormProps = { + action: string + category?: string + className?: string + language?: string + locale: string + placeholder: string + query: string + scope: MarketplaceSearchScope +} + +export function MarketplaceSearchForm({ + action, + category, + className, + language, + locale, + placeholder, + query, + scope, +}: MarketplaceSearchFormProps) { + const [value, setValue] = useState(query) + + return ( +
+ + {language && } + + ) +} diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx index 62131aa5a3d..5834d33d6d4 100644 --- a/web/app/components/plugins/marketplace/templates/index.tsx +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -18,6 +18,7 @@ import HomeSearch from '../home/home-search' import { HomeStickyStateProvider } from '../home/home-sticky-state-provider' import styles from '../home/home-sticky.module.css' import HomeTrending from '../home/home-trending' +import { MarketplaceSearchForm } from '../home/marketplace-search-autocomplete' import { GRID_CLASS } from '../list/collection-constants' import pluginTypeStyles from '../plugin-type-switch.module.css' import { TEMPLATE_CATEGORIES } from './categories' @@ -36,32 +37,6 @@ type EmbeddedTemplatesMarketplaceProps = { type TemplateCategoryLabels = Record -function TemplateSearchForm({ - action, - placeholder, - query, -}: { - action: string - placeholder: string - query: string -}) { - return ( -
- - - - ) -} - function TemplateCategoryNavigation({ activeCategory, ariaLabel, @@ -206,10 +181,14 @@ export async function EmbeddedTemplatesMarketplace({ subtitle={tExplore('apps.description' as never)} /> - {banners.length > 0 && (