fix: restore marketplace search interactions

This commit is contained in:
CodingOnStar 2026-08-05 17:43:08 +08:00
parent b5082d2796
commit ab3bb9dd6c
7 changed files with 604 additions and 35 deletions

View File

@ -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<typeof import('ahooks')>()
return {
...original,
useDebounce: <T,>(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(
<MarketplaceLiveSearch
action="/templates/knowledge"
language="en-US"
placeholder="Search templates"
query=""
/>,
)
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(
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
)
await user.clear(screen.getByRole('searchbox'))
await waitFor(() => {
expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false })
})
})
})

View File

@ -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<typeof import('ahooks')>()
return {
...original,
useDebounce: <T,>(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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
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(
<MarketplaceSearchForm
action="/templates/knowledge"
category="knowledge"
language="en-US"
locale="en-US"
placeholder="Search all templates..."
query=""
scope="templates"
/>,
{ 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 (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={(nextValue) => {
onValueChange(nextValue)
setValue(nextValue)
}}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
)
}
render(<ControlledSearch />, { 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()
})
})

View File

@ -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<HTMLDivElement>(null)
@ -32,14 +32,8 @@ const HomeSearch = ({ children }: { children?: ReactNode }) => {
>
<div ref={searchRef} className="pointer-events-auto relative w-full max-w-[420px]">
{children ?? (
<SearchBoxWrapper
wrapperClassName="w-full max-w-none"
inputClassName="h-9 w-full rounded-[10px] bg-components-input-bg-normal [&>div]:px-2.5"
inputElementClassName="text-[14px] leading-5"
searchIconName="i-ri-search-line"
<MarketplacePluginSearch
placeholder={t(($) => $['marketplace.home.searchPlaceholder'])}
showTags={false}
usedInMarketplace={false}
/>
)}
</div>

View File

@ -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 (
<form
action={action}
className={cn('relative shrink-0', className)}
onSubmit={(event) => {
event.preventDefault()
const nextQuery = value.trim()
routedSearchRef.current = nextQuery
navigate(nextQuery)
}}
>
<span
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
/>
<input
type="search"
name="q"
autoComplete="off"
aria-label={placeholder}
value={value}
onChange={(event) => 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 && <input type="hidden" name="language" value={language} />}
</form>
)
}

View File

@ -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 (
<MarketplaceSearchAutocomplete
category={category}
locale={locale}
onValueChange={(nextValue) => {
void setValue(nextValue)
}}
placeholder={placeholder}
scope="plugins"
value={value}
/>
)
}

View File

@ -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<string, string>, 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, unknown>) => 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 (
<Autocomplete
filter={null}
itemToStringValue={(item) => 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}
>
<AutocompleteInputGroup
size="large"
className="border-[0.5px] border-components-input-border-active"
>
<span
aria-hidden
className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<AutocompleteInput
aria-label={placeholder}
className="px-2 text-sm"
placeholder={placeholder}
size="large"
type="search"
/>
{!!value && (
<AutocompleteClear
aria-label={translate('clearSearch', { ns: 'plugin', label: placeholder })}
size="large"
/>
)}
</AutocompleteInputGroup>
<AutocompleteContent
sideOffset={8}
portalProps={{ hidden: !showDropdown }}
popupClassName="max-w-[420px]"
popupProps={{ 'aria-busy': isSearching || undefined }}
>
{isSearching && suggestions.length === 0 && (
<AutocompleteStatus>
{translate('gotoAnything.searching', { ns: 'app' })}
</AutocompleteStatus>
)}
<AutocompleteList<MarketplaceSuggestion>>
{(item) => (
<AutocompleteItem key={item.id} value={item} className="items-start py-2">
<span
aria-hidden
className={cn(
'mt-0.5 size-4 shrink-0 text-text-tertiary',
item.kind === 'template' ? 'i-ri-layout-grid-line' : 'i-ri-puzzle-2-line',
)}
/>
<span className="flex min-w-0 grow flex-col gap-0.5">
<AutocompleteItemText className="px-0 text-text-primary">
{item.label}
</AutocompleteItemText>
{!!item.description && (
<span className="line-clamp-2 system-xs-regular text-text-tertiary">
{item.description}
</span>
)}
{!!item.meta && (
<span className="truncate system-xs-regular text-text-quaternary">
{item.meta}
</span>
)}
</span>
<AutocompleteItemIndicator />
</AutocompleteItem>
)}
</AutocompleteList>
{!isSearching && <AutocompleteEmpty>{emptyText}</AutocompleteEmpty>}
</AutocompleteContent>
</Autocomplete>
)
}
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 (
<form action={action} className={cn('relative shrink-0', className)}>
<MarketplaceSearchAutocomplete
category={category}
inputName="q"
locale={locale}
onValueChange={setValue}
placeholder={placeholder}
scope={scope}
value={value}
/>
{language && <input type="hidden" name="language" value={language} />}
</form>
)
}

View File

@ -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<TemplateCategory, string>
function TemplateSearchForm({
action,
placeholder,
query,
}: {
action: string
placeholder: string
query: string
}) {
return (
<form action={action} className="relative w-full shrink-0">
<span
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
/>
<input
type="search"
name="q"
defaultValue={query}
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"
/>
</form>
)
}
function TemplateCategoryNavigation({
activeCategory,
ariaLabel,
@ -206,10 +181,14 @@ export async function EmbeddedTemplatesMarketplace({
subtitle={tExplore('apps.description' as never)}
/>
<HomeSearch>
<TemplateSearchForm
<MarketplaceSearchForm
action={category === 'all' ? '/templates' : `/templates/${category}`}
category={category}
className="w-full"
locale={locale}
placeholder={tApp('newAppFromTemplate.searchAllTemplate' as never)}
query={query}
scope="templates"
/>
</HomeSearch>
{banners.length > 0 && (