mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 16:55:14 +08:00
fix: align marketplace search suggestions (ECO-455)
This commit is contained in:
parent
8dc32a121b
commit
e2add239a8
@ -34,7 +34,10 @@ vi.mock('react-i18next', async (importOriginal) => {
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'marketplace.viewMore': 'View more',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
}),
|
||||
}
|
||||
@ -163,7 +166,7 @@ describe('Marketplace search autocomplete layout', () => {
|
||||
expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
|
||||
})
|
||||
|
||||
it('uses the specified panel, list, and item spacing without a bottom strip', async () => {
|
||||
it('matches the reference grouped panel and compact result spacing', async () => {
|
||||
await page.viewport(1280, 720)
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
@ -208,32 +211,38 @@ describe('Marketplace search autocomplete layout', () => {
|
||||
|
||||
const list = screen.getByRole('listbox').element()
|
||||
const panel = list.parentElement!
|
||||
const templateGroup = screen.getByRole('group', { name: 'Templates' }).element()
|
||||
const firstItem = screen.getByRole('option', { name: /Legal Research Agent/ }).element()
|
||||
const lastItem = screen.getByRole('option', { name: /Contract Reviewer/ }).element()
|
||||
const panelStyle = getComputedStyle(panel)
|
||||
const listStyle = getComputedStyle(list)
|
||||
const templateGroupStyle = getComputedStyle(templateGroup)
|
||||
const firstItemStyle = getComputedStyle(firstItem)
|
||||
const statusRoots = screen.getByRole('status').all()
|
||||
const trailingStatus = statusRoots.at(-1)!.element()
|
||||
|
||||
expect(panelStyle.paddingTop).toBe('8px')
|
||||
expect(panelStyle.paddingRight).toBe('8px')
|
||||
expect(panelStyle.paddingBottom).toBe('8px')
|
||||
expect(panelStyle.paddingLeft).toBe('8px')
|
||||
expect(listStyle.rowGap).toBe('4px')
|
||||
expect(panelStyle.width).toBe('472px')
|
||||
expect(panelStyle.paddingTop).toBe('0px')
|
||||
expect(panelStyle.paddingRight).toBe('0px')
|
||||
expect(panelStyle.paddingBottom).toBe('0px')
|
||||
expect(panelStyle.paddingLeft).toBe('0px')
|
||||
expect(panelStyle.borderRadius).toBe('12px')
|
||||
expect(listStyle.paddingTop).toBe('0px')
|
||||
expect(firstItemStyle.paddingTop).toBe('12px')
|
||||
expect(firstItemStyle.paddingRight).toBe('12px')
|
||||
expect(firstItemStyle.paddingBottom).toBe('12px')
|
||||
expect(templateGroupStyle.paddingTop).toBe('4px')
|
||||
expect(templateGroupStyle.paddingRight).toBe('4px')
|
||||
expect(templateGroupStyle.paddingBottom).toBe('4px')
|
||||
expect(templateGroupStyle.paddingLeft).toBe('4px')
|
||||
expect(firstItemStyle.paddingTop).toBe('4px')
|
||||
expect(firstItemStyle.paddingRight).toBe('4px')
|
||||
expect(firstItemStyle.paddingBottom).toBe('4px')
|
||||
expect(firstItemStyle.paddingLeft).toBe('12px')
|
||||
expect(firstItemStyle.borderRadius).toBe('12px')
|
||||
expect(firstItemStyle.borderRadius).toBe('8px')
|
||||
expect(firstItemStyle.marginLeft).toBe('0px')
|
||||
expect(firstItemStyle.marginRight).toBe('0px')
|
||||
expect(trailingStatus.getBoundingClientRect().height).toBe(0)
|
||||
expect(firstItem.getBoundingClientRect().top - panel.getBoundingClientRect().top).toBeCloseTo(9)
|
||||
expect(
|
||||
panel.getBoundingClientRect().bottom - lastItem.getBoundingClientRect().bottom,
|
||||
).toBeCloseTo(9)
|
||||
).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('keeps result rows fully clickable without a persistent trailing arrow', async () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
@ -35,7 +35,10 @@ vi.mock('react-i18next', async () => {
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'marketplace.viewMore': 'View more',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
})
|
||||
})
|
||||
@ -186,6 +189,70 @@ describe('MarketplaceSearchAutocomplete', () => {
|
||||
expect(mockTemplateSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('groups mixed suggestions and submits the complete search from the popup', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
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,
|
||||
},
|
||||
})
|
||||
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 handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins or templates"
|
||||
query=""
|
||||
scope="all"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
|
||||
const templateGroup = await screen.findByRole('group', { name: 'Templates' })
|
||||
const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
|
||||
expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'View more' }))
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits the route search form when a suggestion is chosen', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@ -4,7 +4,10 @@ import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/mar
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteClear,
|
||||
AutocompleteCollection,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteInput,
|
||||
AutocompleteInputGroup,
|
||||
AutocompleteItem,
|
||||
@ -12,7 +15,9 @@ import {
|
||||
AutocompleteList,
|
||||
AutocompletePortal,
|
||||
AutocompletePositioner,
|
||||
AutocompleteSeparator,
|
||||
AutocompleteStatus,
|
||||
useAutocompleteFilteredItems,
|
||||
} from '@langgenius/dify-ui/autocomplete'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@ -41,6 +46,12 @@ type MarketplaceSuggestion = {
|
||||
selection: MarketplaceSearchSelection
|
||||
}
|
||||
|
||||
type MarketplaceSuggestionGroup = {
|
||||
id: MarketplaceSuggestion['kind']
|
||||
items: MarketplaceSuggestion[]
|
||||
label: string
|
||||
}
|
||||
|
||||
type MarketplaceSearchAutocompleteProps = {
|
||||
category?: string
|
||||
inputName?: string
|
||||
@ -82,6 +93,96 @@ 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) {
|
||||
const groups = useAutocompleteFilteredItems<MarketplaceSuggestionGroup>()
|
||||
|
||||
return (
|
||||
<AutocompleteList className="max-h-none overflow-visible p-0 data-empty:p-0">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<AutocompleteGroup key={group.id} items={group.items} className="p-1">
|
||||
{groupIndex > 0 && <AutocompleteSeparator className="-mx-1 mb-1" />}
|
||||
<AutocompleteGroupLabel className="px-3 pt-3 pb-2 system-xs-semibold-uppercase text-text-primary">
|
||||
{group.label}
|
||||
</AutocompleteGroupLabel>
|
||||
<AutocompleteCollection<MarketplaceSuggestion>>
|
||||
{(item) => (
|
||||
<AutocompleteItem
|
||||
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
|
||||
}
|
||||
>
|
||||
<span className="flex shrink-0 items-start py-1">
|
||||
{item.iconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className={cn(
|
||||
'shrink-0 object-contain',
|
||||
item.kind === 'template'
|
||||
? 'size-8 rounded-lg border-[0.5px] border-divider-regular'
|
||||
: 'size-7 rounded-lg',
|
||||
)}
|
||||
src={item.iconUrl}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center text-text-tertiary',
|
||||
item.kind === 'template'
|
||||
? 'i-ri-layout-grid-line size-8 rounded-lg border-[0.5px] border-divider-regular text-base'
|
||||
: 'i-ri-puzzle-2-line size-7 rounded-lg text-base',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5 p-1">
|
||||
<AutocompleteItemText className="px-0 system-md-medium 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 pt-1 system-xs-regular text-text-tertiary">
|
||||
{item.meta}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
</AutocompleteGroup>
|
||||
))}
|
||||
</AutocompleteList>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarketplaceSearchAutocomplete({
|
||||
category = 'all',
|
||||
inputName,
|
||||
@ -153,6 +254,26 @@ export function MarketplaceSearchAutocomplete({
|
||||
? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion)
|
||||
: []
|
||||
const suggestions = [...templateSuggestions, ...pluginSuggestions]
|
||||
const suggestionGroups: MarketplaceSuggestionGroup[] = [
|
||||
...(templateSuggestions.length
|
||||
? [
|
||||
{
|
||||
id: 'template' as const,
|
||||
items: templateSuggestions,
|
||||
label: t(($) => $['marketplace.home.templates'], { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pluginSuggestions.length
|
||||
? [
|
||||
{
|
||||
id: 'plugin' as const,
|
||||
items: pluginSuggestions,
|
||||
label: t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
|
||||
// Keep open tied to the typing session so outside-press can dismiss during
|
||||
// debounce/fetch. Pending, empty, and error copy live inside the popup.
|
||||
@ -190,7 +311,7 @@ export function MarketplaceSearchAutocomplete({
|
||||
<Autocomplete
|
||||
filter={null}
|
||||
itemToStringValue={(item) => item.label}
|
||||
items={suggestions}
|
||||
items={suggestionGroups}
|
||||
mode="list"
|
||||
name={inputName}
|
||||
onOpenChange={setIsOpen}
|
||||
@ -226,69 +347,42 @@ export function MarketplaceSearchAutocomplete({
|
||||
<AutocompletePositioner sideOffset={8}>
|
||||
<div
|
||||
ref={resultsPanelRef}
|
||||
className="w-(--anchor-width) max-w-[420px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg outline-hidden"
|
||||
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"
|
||||
aria-busy={isSearching || undefined}
|
||||
>
|
||||
<AutocompleteList<MarketplaceSuggestion> className="flex flex-col gap-1 p-0 data-empty:p-0">
|
||||
{(item) => (
|
||||
<AutocompleteItem
|
||||
key={item.id}
|
||||
value={item}
|
||||
className="mx-0 items-start rounded-xl p-3"
|
||||
onClick={
|
||||
onSuggestionSelect
|
||||
? () => {
|
||||
onSuggestionSelect(item.selection)
|
||||
queueMicrotask(() => {
|
||||
onValueChange('')
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.iconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="mt-0.5 size-6 shrink-0 rounded-md object-contain"
|
||||
src={item.iconUrl}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<MarketplaceSuggestionList
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={onValueChange}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
<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={() => {
|
||||
const form = searchRootRef.current?.closest('form')
|
||||
if (form instanceof HTMLFormElement) form.requestSubmit()
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user