mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix: prevent marketplace search layout shift (ECO-462)
This commit is contained in:
parent
54f4073f22
commit
abb8f5c239
@ -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<typeof import('ahooks')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
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<typeof import('@/service/client')>()
|
||||
|
||||
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function StickyTemplateSearch() {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search templates"
|
||||
scope="templates"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PinnedHeaderState() {
|
||||
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-12 items-center bg-background-default">
|
||||
<span>Dify Marketplace</span>
|
||||
{isCatalogPinned && (
|
||||
<div role="tablist" aria-label="Header catalog tabs">
|
||||
Plugins and templates
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<HomeStickyStateProvider>
|
||||
<div
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
data-marketplace-standalone
|
||||
data-testid="marketplace-scroll-container"
|
||||
className="h-[360px] w-[1200px] overflow-y-auto"
|
||||
>
|
||||
<PinnedHeaderState />
|
||||
<div className="h-[180px]" aria-hidden />
|
||||
<HomeSearch enableSearchShortcut={false}>
|
||||
<StickyTemplateSearch />
|
||||
</HomeSearch>
|
||||
<HomeCatalogNavigation
|
||||
catalogCategories={<div role="group" aria-label="Template categories" />}
|
||||
catalogTabs={<div role="tablist" aria-label="Catalog tabs" />}
|
||||
/>
|
||||
<main aria-label="Template catalog" className="h-[900px]" />
|
||||
</div>
|
||||
</HomeStickyStateProvider>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -363,13 +363,16 @@ describe('MarketplaceSearchAutocomplete', () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
<>
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
<button type="button">Outside search</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>(null)
|
||||
const resultsPanelRef = useRef<HTMLDivElement>(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 (
|
||||
<Autocomplete
|
||||
filter={null}
|
||||
itemToStringValue={(item) => 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}
|
||||
>
|
||||
<AutocompleteInputGroup size="large">
|
||||
<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="text"
|
||||
/>
|
||||
{!!value && (
|
||||
<AutocompleteClear
|
||||
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
|
||||
size="large"
|
||||
<div ref={searchRootRef} className="relative">
|
||||
<Autocomplete
|
||||
filter={null}
|
||||
itemToStringValue={(item) => 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}
|
||||
>
|
||||
<AutocompleteInputGroup size="large">
|
||||
<span
|
||||
aria-hidden
|
||||
className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
/>
|
||||
)}
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompletePortal hidden={!isPopupOpen}>
|
||||
<AutocompletePositioner sideOffset={8}>
|
||||
<AutocompletePopup className="max-w-[420px]" aria-busy={isSearching || undefined}>
|
||||
<AutocompleteList<MarketplaceSuggestion>>
|
||||
{(item) => (
|
||||
<AutocompleteItem
|
||||
key={item.id}
|
||||
value={item}
|
||||
className="items-start py-2"
|
||||
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',
|
||||
<AutocompleteInput
|
||||
aria-label={placeholder}
|
||||
className="px-2 text-sm"
|
||||
placeholder={placeholder}
|
||||
size="large"
|
||||
type="text"
|
||||
/>
|
||||
{!!value && (
|
||||
<AutocompleteClear
|
||||
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompletePortal hidden={!isPopupOpen}>
|
||||
<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 shadow-lg outline-hidden"
|
||||
aria-busy={isSearching || undefined}
|
||||
>
|
||||
<AutocompleteList<MarketplaceSuggestion>>
|
||||
{(item) => (
|
||||
<AutocompleteItem
|
||||
key={item.id}
|
||||
value={item}
|
||||
className="items-start py-2"
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<AutocompleteEmpty>
|
||||
{!isSearching && suggestions.length === 0 ? emptyText : null}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteStatus>
|
||||
{isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
|
||||
</AutocompleteStatus>
|
||||
</AutocompletePopup>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
</Autocomplete>
|
||||
{!!item.meta && (
|
||||
<span className="truncate system-xs-regular text-text-quaternary">
|
||||
{item.meta}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<AutocompleteItemIndicator />
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>
|
||||
{!isSearching && suggestions.length === 0 ? emptyText : null}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteStatus>
|
||||
{isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
|
||||
</AutocompleteStatus>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user