mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix(web): open marketplace search suggestions as details
Drop View more from the autocomplete popup, match the panel width to the search field, and send a chosen plugin or template to its detail instead of submitting /search/all.
This commit is contained in:
parent
630a3bd5d2
commit
0960ef4f0f
@ -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')
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<typeof import('ahooks')>()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<MarketplaceSuggestionGroup>()
|
||||
|
||||
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)}
|
||||
>
|
||||
<span className="flex shrink-0 items-start py-1">
|
||||
{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<HTMLDivElement>(null)
|
||||
const resultsPanelRef = useRef<HTMLDivElement>(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({
|
||||
)}
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompletePortal hidden={!isPopupOpen}>
|
||||
<AutocompletePositioner sideOffset={8}>
|
||||
<AutocompletePositioner anchor={searchRootRef} sideOffset={8}>
|
||||
<div
|
||||
ref={resultsPanelRef}
|
||||
className="max-h-[min(710px,var(--available-height))] w-[472px] max-w-[min(calc(100vw-32px),var(--available-width))] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl outline-hidden backdrop-blur-sm"
|
||||
className="max-h-[min(710px,var(--available-height))] w-(--anchor-width) max-w-(--available-width) overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl outline-hidden backdrop-blur-sm"
|
||||
aria-busy={isSearching || undefined}
|
||||
>
|
||||
<MarketplaceSuggestionList
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={onValueChange}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
<MarketplaceSuggestionList onSelect={openSuggestion} />
|
||||
<AutocompleteEmpty>
|
||||
{!isSearching && suggestions.length === 0 ? emptyText : null}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteStatus className="empty:h-0 empty:p-0">
|
||||
{isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
|
||||
</AutocompleteStatus>
|
||||
{Boolean(inputName) && suggestions.length > 0 && !isSearching && (
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
onClick={submitSearchForm}
|
||||
>
|
||||
<span className="system-sm-medium text-text-accent">
|
||||
{t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="rounded-[5px] border border-divider-deep px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary group-hover:hidden"
|
||||
>
|
||||
Enter
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user