From e2add239a8dd253446b2aeb83812e5ce2d28ed16 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 28 Aug 2026 11:35:30 +0800 Subject: [PATCH] fix: align marketplace search suggestions (ECO-455) --- ...place-search-autocomplete.browser.spec.tsx | 33 ++- .../marketplace-search-autocomplete.spec.tsx | 69 +++++- .../home/marketplace-search-autocomplete.tsx | 206 +++++++++++++----- 3 files changed, 239 insertions(+), 69 deletions(-) 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 60ddb9d3634..26283fd2e94 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 @@ -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 () => { 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 1b511f19108..78609985b0b 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 @@ -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( + , + { 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: { 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 e13a17637e2..8303ef0bfb8 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -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() + + return ( + + {groups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + + {group.label} + + > + {(item) => ( + { + onSuggestionSelect(item.selection) + queueMicrotask(() => { + onValueChange('') + setIsOpen(false) + }) + } + : undefined + } + > + + {item.iconUrl ? ( + { + currentTarget.style.display = 'none' + }} + /> + ) : ( + + )} + + + + {item.label} + + {!!item.description && ( + + {item.description} + + )} + {!!item.meta && ( + + {item.meta} + + )} + + + )} + + + ))} + + ) +} + 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({ item.label} - items={suggestions} + items={suggestionGroups} mode="list" name={inputName} onOpenChange={setIsOpen} @@ -226,69 +347,42 @@ export function MarketplaceSearchAutocomplete({
- className="flex flex-col gap-1 p-0 data-empty:p-0"> - {(item) => ( - { - onSuggestionSelect(item.selection) - queueMicrotask(() => { - onValueChange('') - setIsOpen(false) - }) - } - : undefined - } - > - {item.iconUrl ? ( - { - currentTarget.style.display = 'none' - }} - /> - ) : ( - - )} - - - {item.label} - - {!!item.description && ( - - {item.description} - - )} - {!!item.meta && ( - - {item.meta} - - )} - - - )} - + {!isSearching && suggestions.length === 0 ? emptyText : null} {isSearching ? t(($) => $.loading, { ns: 'common' }) : null} + {Boolean(inputName) && suggestions.length > 0 && !isSearching && ( +
+ +
+ )}