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 new file mode 100644 index 00000000000..fb1cdb876fa --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -0,0 +1,164 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeSearch from '../home-search' +import { homeCatalogPinnedAtom } from '../home-sticky-state' +import { HomeStickyStateProvider } from '../home-sticky-state-provider' +import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete' + +const { mockTemplateSearch } = vi.hoisted(() => ({ + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + useDebounce: (value: T) => value, + } +}) + +vi.mock('react-i18next', async (importOriginal) => { + const original = await importOriginal() + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return { + ...original, + ...createReactI18nextMock({ + clearSearch: 'Clear search', + loading: 'Loading', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + }), + } +}) + +vi.mock('@/service/client', async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => ({ data: { plugins: [], total: 0 } }), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, + } +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, +}) + +function Wrapper({ children }: { children: ReactNode }) { + return {children} +} + +function StickyTemplateSearch() { + const [value, setValue] = useState('') + + return ( + + ) +} + +function PinnedHeaderState() { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( +
+ Dify Marketplace + {isCatalogPinned && ( +
+ Plugins and templates +
+ )} +
+ ) +} + +describe('Marketplace search autocomplete layout', () => { + beforeEach(() => { + queryClient.clear() + mockTemplateSearch.mockReset() + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('keeps the pinned catalog layout stable while the results popup opens', async () => { + await page.viewport(1280, 720) + + const screen = await render( + + +
+ +
+ + + + } + catalogTabs={
} + /> +
+
+ + , + ) + + const scrollContainer = screen.getByTestId('marketplace-scroll-container').element() + scrollContainer.scrollTop = 220 + scrollContainer.dispatchEvent(new Event('scroll')) + await new Promise(requestAnimationFrame) + + const input = screen.getByRole('combobox', { name: 'Search templates' }) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + + const catalogNavigation = screen + .getByRole('region', { name: 'common.mainNav.marketplace' }) + .element() + const scrollTopBefore = scrollContainer.scrollTop + const inputTopBefore = input.element().getBoundingClientRect().top + const navigationTopBefore = catalogNavigation.getBoundingClientRect().top + + await input.fill('open') + await expect.element(screen.getByText('No templates found')).toBeVisible() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore) + }) +}) 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 d51de43accf..bcd6eb94f4d 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 @@ -363,13 +363,16 @@ describe('MarketplaceSearchAutocomplete', () => { const [value, setValue] = useState('') return ( - + <> + + + ) } @@ -378,10 +381,10 @@ describe('MarketplaceSearchAutocomplete', () => { await user.type(screen.getByRole('combobox'), 'google') expect(screen.getByText(/Loading/)).toBeInTheDocument() - // Base UI marks the rest of the document inert while the popup is open, - // so the outside-press target is its Dismiss control — not a sibling button. - await user.click(screen.getAllByRole('button', { name: 'Dismiss' })[0]!) - expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Outside search' })) + await waitFor(() => { + expect(screen.getByText(/Loading/)).not.toBeVisible() + }) resolvePluginSearch(pluginResponse) 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 6ff92de60fa..22bde8f06d5 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -11,7 +11,6 @@ import { AutocompleteItemIndicator, AutocompleteItemText, AutocompleteList, - AutocompletePopup, AutocompletePortal, AutocompletePositioner, AutocompleteStatus, @@ -19,7 +18,7 @@ import { import { cn } from '@langgenius/dify-ui/cn' import { useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from '#i18n' import { MARKETPLACE_API_PREFIX } from '@/config' import { renderI18nObject } from '@/i18n-config/index' @@ -96,6 +95,8 @@ export function MarketplaceSearchAutocomplete({ }: MarketplaceSearchAutocompleteProps) { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) + const searchRootRef = useRef(null) + const resultsPanelRef = useRef(null) const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) const hasQuery = Boolean(debouncedSearch) const searchesPlugins = scope === 'all' || scope === 'plugins' @@ -170,110 +171,131 @@ export function MarketplaceSearchAutocomplete({ ? t(($) => $['newApp.noTemplateFound'], { ns: 'app' }) : t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' }) + useEffect(() => { + if (!isPopupOpen) return + + const handleOutsidePress = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (searchRootRef.current?.contains(target) || resultsPanelRef.current?.contains(target)) + return + setIsOpen(false) + } + + document.addEventListener('click', handleOutsidePress) + return () => document.removeEventListener('click', handleOutsidePress) + }, [isPopupOpen]) + return ( - item.label} - items={suggestions} - mode="list" - name={inputName} - onOpenChange={setIsOpen} - onValueChange={(nextValue) => { - onValueChange(nextValue) - setIsOpen(Boolean(nextValue.trim())) - }} - open={isPopupOpen} - openOnInputClick - submitOnItemClick={Boolean(inputName)} - value={value} - > - - - - {!!value && ( - $.clearSearch, { ns: 'plugin', label: placeholder })} - size="large" +
+ item.label} + items={suggestions} + mode="list" + name={inputName} + onOpenChange={setIsOpen} + onValueChange={(nextValue) => { + onValueChange(nextValue) + setIsOpen(Boolean(nextValue.trim())) + }} + open={isPopupOpen} + openOnInputClick + submitOnItemClick={Boolean(inputName)} + value={value} + > + + - )} - - +
) }