mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
fix(web): open embedded recommend banner plugins in the detail dialog (#41828)
Co-authored-by: fatelei <fatelei@gmail.com> Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: 姜涵煦 <hanxujiang@jianghanxudeMacBook-Pro-2.local> Co-authored-by: L1nSn0w <l1nsn0w@qq.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
parent
4b0e260ac5
commit
762dc5e8a6
@ -0,0 +1,40 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { highlightCode } from '../shiki-highlight'
|
||||
|
||||
describe('README code highlighting', () => {
|
||||
it.each(['github-light', 'github-dark'] as const)('highlights dotenv with %s', async (theme) => {
|
||||
const code = 'OPENAI_API_KEY=your-api-key\n# OPENAI_ORGANIZATION=org-id'
|
||||
const result = renderToStaticMarkup(await highlightCode({ code, language: 'dotenv', theme }))
|
||||
|
||||
expect(result).toContain('OPENAI_API_KEY')
|
||||
expect(result).toContain('your-api-key')
|
||||
expect(result).toContain('OPENAI_ORGANIZATION=org-id')
|
||||
expect(result).toContain('<span style="color:')
|
||||
})
|
||||
|
||||
it('renders unsupported languages as readable, escaped plain text', async () => {
|
||||
const code = '<custom>example</custom>'
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({
|
||||
code,
|
||||
language: 'unknown-readme-language',
|
||||
theme: 'github-light',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain('<custom>example</custom>')
|
||||
})
|
||||
|
||||
it('preserves highlighting for bundled language aliases', async () => {
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({
|
||||
code: 'const count = 1',
|
||||
language: 'js',
|
||||
theme: 'github-light',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain('const')
|
||||
expect(result).toContain('<span style="color:')
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
|
||||
import type { BundledTheme } from 'shiki/bundle/web'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Toggle } from '@langgenius/dify-ui/toggle'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
@ -78,7 +78,7 @@ const ShikiCodeBlock = memo(
|
||||
|
||||
void highlightCode({
|
||||
code,
|
||||
language: language as BundledLanguage,
|
||||
language,
|
||||
theme,
|
||||
})
|
||||
.then((result) => {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
|
||||
import type { BundledTheme } from 'shiki/bundle/web'
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
|
||||
import { Fragment } from 'react'
|
||||
import { jsx, jsxs } from 'react/jsx-runtime'
|
||||
import { codeToHast } from 'shiki/bundle/web'
|
||||
import { bundledLanguages, getSingletonHighlighter } from 'shiki/bundle/web'
|
||||
|
||||
type HighlightCodeOptions = {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
language: string
|
||||
theme: BundledTheme
|
||||
}
|
||||
|
||||
@ -16,8 +16,19 @@ export const highlightCode = async ({
|
||||
language,
|
||||
theme,
|
||||
}: HighlightCodeOptions): Promise<JSX.Element> => {
|
||||
const hast = await codeToHast(code, {
|
||||
lang: language,
|
||||
const normalizedLanguage = language.trim().toLowerCase()
|
||||
const lang =
|
||||
normalizedLanguage === 'dotenv' || Object.hasOwn(bundledLanguages, normalizedLanguage)
|
||||
? normalizedLanguage
|
||||
: 'text'
|
||||
// README fences may name languages outside the web bundle. Load dotenv on
|
||||
// demand and keep unknown languages readable without throwing an error.
|
||||
const highlighter = await getSingletonHighlighter({
|
||||
langs: lang === 'dotenv' ? [(await import('shiki/langs/dotenv.mjs')).default] : [lang],
|
||||
themes: [theme],
|
||||
})
|
||||
const hast = highlighter.codeToHast(code, {
|
||||
lang,
|
||||
theme,
|
||||
})
|
||||
|
||||
|
||||
@ -163,6 +163,34 @@ describe('useMarketplaceData', () => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('restores collections and clears pending results when switching Models back to All', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { useActivePluginType } = await import('../atoms')
|
||||
const { Wrapper } = createWrapper('?category=model')
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
data: useMarketplaceData(),
|
||||
setCategory: useActivePluginType()[1],
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.plugins).toHaveLength(1)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setCategory(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.marketplaceCollections).toHaveLength(1)
|
||||
expect(result.current.data.plugins).toBeUndefined()
|
||||
expect(result.current.data.pluginsTotal).toBeUndefined()
|
||||
expect(result.current.data.isRefreshing).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use the server route category for hydrated standalone search', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?q=openai')
|
||||
|
||||
@ -37,6 +37,31 @@ vi.mock('@/config', async (importOriginal) => ({
|
||||
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchPluginInfoFromMarketPlace: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
plugin: {
|
||||
category: 'tool',
|
||||
latest_package_identifier: 'langgenius/dropbox:1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../detail-dialog', () => ({
|
||||
default: ({ open, plugin }: { open: boolean; plugin: { name: string } }) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="plugin-detail">
|
||||
{plugin.name}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
const banners: PluginBanner[] = [
|
||||
{
|
||||
id: 'recommend',
|
||||
@ -678,7 +703,8 @@ describe('HomeTrending', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends embedded cards without a delivery link to the marketplace site', () => {
|
||||
it('opens embedded recommend plugin cards in the plugin dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const bannerWithMixedLinks: PluginBanner = {
|
||||
id: 'recommend-mixed',
|
||||
style_type: 'recommend',
|
||||
@ -696,8 +722,6 @@ describe('HomeTrending', () => {
|
||||
card_position: 0,
|
||||
},
|
||||
{
|
||||
// The console has no local /plugin route, so a card without a
|
||||
// delivery-provided link must open the marketplace detail page.
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/notion',
|
||||
display_name: 'Notion',
|
||||
@ -723,19 +747,34 @@ describe('HomeTrending', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://external.example.com/dropbox',
|
||||
)
|
||||
const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
|
||||
expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
|
||||
/^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,
|
||||
)
|
||||
expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
|
||||
expect(screen.queryByRole('link', { name: 'Dropbox' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Notion' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
|
||||
'href',
|
||||
'/templates?tid=tpl-1',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dropbox' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('dropbox')
|
||||
expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
|
||||
'marketplace_banner_click',
|
||||
expect.objectContaining({
|
||||
click_target: 'recommendation',
|
||||
item_id: 'langgenius/dropbox',
|
||||
item_type: 'plugin',
|
||||
item_name: 'Dropbox',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps standalone recommend plugin cards on local detail routes', () => {
|
||||
render(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plugin/langgenius/dropbox',
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps the active slide when a refetch shrinks the banner list', async () => {
|
||||
|
||||
@ -9,17 +9,23 @@ import type {
|
||||
PluginBanner,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import type { MarketplaceBannerPage } from './banners'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Partner from '@/app/components/plugins/base/badges/partner'
|
||||
import Verified from '@/app/components/plugins/base/badges/verified'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import Link from '@/next/link'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import {
|
||||
rememberMarketplaceSiteReferrer,
|
||||
trackMarketplaceSiteEvent,
|
||||
} from '@/utils/marketplace-site-track'
|
||||
import MarketplaceDetailDialog from '../detail-dialog'
|
||||
import { getPluginLinkInMarketplace } from '../utils'
|
||||
import background from './assets/background.webp'
|
||||
import difyUpdatesArt from './assets/dify-updates-art.png'
|
||||
@ -46,11 +52,49 @@ const getMarketplaceAssetURL = (path?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getPluginIdentity = (itemId: string) => {
|
||||
const [org, name] = itemId.split('/')
|
||||
if (!org || !name) return null
|
||||
return { org, name }
|
||||
}
|
||||
|
||||
const pluginFromRecommendCard = (card: BannerRecommendCard): Plugin | null => {
|
||||
if (card.item_type !== 'plugin') return null
|
||||
const identity = getPluginIdentity(card.item_id)
|
||||
if (!identity) return null
|
||||
|
||||
return {
|
||||
type: 'plugin',
|
||||
org: identity.org,
|
||||
name: identity.name,
|
||||
plugin_id: card.item_id,
|
||||
version: '',
|
||||
latest_version: '',
|
||||
latest_package_identifier: '',
|
||||
icon: card.icon_url ?? '',
|
||||
verified: Boolean(card.badges?.includes('verified')),
|
||||
label: { 'en-US': card.display_name },
|
||||
brief: {},
|
||||
description: {},
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: card.badges ?? null,
|
||||
verification: {
|
||||
authorized_category: card.badges?.includes('partner') ? 'partner' : 'community',
|
||||
},
|
||||
from: 'marketplace',
|
||||
}
|
||||
}
|
||||
|
||||
const getLocalCardHref = (card: BannerRecommendCard) => {
|
||||
if (card.item_type === 'plugin') {
|
||||
const [organization, pluginName] = card.item_id.split('/')
|
||||
if (organization && pluginName)
|
||||
return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
|
||||
const identity = getPluginIdentity(card.item_id)
|
||||
if (identity)
|
||||
return `/plugin/${encodeURIComponent(identity.org)}/${encodeURIComponent(identity.name)}`
|
||||
}
|
||||
|
||||
if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
|
||||
@ -63,17 +107,14 @@ const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean)
|
||||
const deliveryHref = card.link ? sanitizeMarketplaceHref(card.link) : null
|
||||
if (deliveryHref) return deliveryHref
|
||||
|
||||
// The embedded console has no local plugin detail route, so a plugin card
|
||||
// without a delivery-provided link opens the marketplace site detail page.
|
||||
if (card.item_type === 'plugin') {
|
||||
const [organization, pluginName] = card.item_id.split('/')
|
||||
if (organization && pluginName)
|
||||
return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' })
|
||||
}
|
||||
|
||||
return getLocalCardHref(card)
|
||||
}
|
||||
|
||||
const recommendCardClassName = cn(
|
||||
styles.card,
|
||||
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)
|
||||
|
||||
const getCardCreator = (card: BannerRecommendCard) => {
|
||||
if (card.creator) return card.creator
|
||||
if (card.item_type !== 'plugin') return ''
|
||||
@ -141,54 +182,38 @@ function TrendingCopy({
|
||||
)
|
||||
}
|
||||
|
||||
function TrendingCard({
|
||||
banner,
|
||||
card,
|
||||
isMarketplacePlatform,
|
||||
page,
|
||||
}: {
|
||||
banner: BannerRecommend
|
||||
card: BannerRecommendCard
|
||||
isMarketplacePlatform: boolean
|
||||
page: MarketplaceBannerPage
|
||||
}) {
|
||||
function trackRecommendCardClick(
|
||||
banner: BannerRecommend,
|
||||
card: BannerRecommendCard,
|
||||
page: MarketplaceBannerPage,
|
||||
href: string,
|
||||
) {
|
||||
trackEvent('marketplace_banner_item_click', {
|
||||
...getBannerFrameProps(banner, page),
|
||||
item_type: card.item_type,
|
||||
item_id: card.item_id,
|
||||
card_position: card.card_position,
|
||||
theme_type: banner.content.theme_type,
|
||||
auto_batch_id: card.auto_batch_id ?? null,
|
||||
})
|
||||
rememberMarketplaceSiteReferrer(card.item_id, 'banner')
|
||||
trackMarketplaceBannerClick(banner, {
|
||||
item_id: card.item_id,
|
||||
item_type: card.item_type,
|
||||
display_name: card.display_name,
|
||||
link: href,
|
||||
})
|
||||
}
|
||||
|
||||
function RecommendCardFace({ card }: { card: BannerRecommendCard }) {
|
||||
const { t } = useTranslation('plugin')
|
||||
const iconURL = getMarketplaceAssetURL(card.icon_url)
|
||||
const creator = getCardCreator(card)
|
||||
const href = getCardHref(card, isMarketplacePlatform)
|
||||
if (!href) return null
|
||||
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
|
||||
const isPartner = card.badges?.includes('partner')
|
||||
const isVerified = card.badges?.includes('verified')
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={opensInNewTab ? '_blank' : undefined}
|
||||
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
|
||||
aria-label={card.display_name}
|
||||
onClick={() => {
|
||||
trackEvent('marketplace_banner_item_click', {
|
||||
...getBannerFrameProps(banner, page),
|
||||
item_type: card.item_type,
|
||||
item_id: card.item_id,
|
||||
card_position: card.card_position,
|
||||
theme_type: banner.content.theme_type,
|
||||
auto_batch_id: card.auto_batch_id ?? null,
|
||||
})
|
||||
rememberMarketplaceSiteReferrer(card.item_id, 'banner')
|
||||
trackMarketplaceBannerClick(banner, {
|
||||
item_id: card.item_id,
|
||||
item_type: card.item_type,
|
||||
display_name: card.display_name,
|
||||
link: href,
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
styles.card,
|
||||
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
styles.cardIcon,
|
||||
@ -241,6 +266,110 @@ function TrendingCard({
|
||||
{t(($) => $['marketplace.home.trendingView'])}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmbeddedRecommendPluginCard({
|
||||
banner,
|
||||
card,
|
||||
initialPlugin,
|
||||
page,
|
||||
}: {
|
||||
banner: BannerRecommend
|
||||
card: BannerRecommendCard
|
||||
initialPlugin: Plugin
|
||||
page: MarketplaceBannerPage
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [plugin, setPlugin] = useState(initialPlugin)
|
||||
if (plugin.plugin_id !== initialPlugin.plugin_id) setPlugin(initialPlugin)
|
||||
const { installedInfo } = useCheckInstalled({
|
||||
pluginIds: [plugin.plugin_id],
|
||||
enabled: open,
|
||||
})
|
||||
const href = getPluginLinkInMarketplace({
|
||||
org: plugin.org,
|
||||
name: plugin.name,
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={card.display_name}
|
||||
className={cn(recommendCardClassName, 'cursor-pointer border-0 text-left')}
|
||||
onClick={() => {
|
||||
trackRecommendCardClick(banner, card, page, href)
|
||||
setOpen(true)
|
||||
if (plugin.latest_package_identifier) return
|
||||
|
||||
void fetchPluginInfoFromMarketPlace({ org: plugin.org, name: plugin.name })
|
||||
.then((response) => {
|
||||
const info = response.data.plugin
|
||||
setPlugin((current) => ({
|
||||
...current,
|
||||
latest_package_identifier: info.latest_package_identifier,
|
||||
latest_version: info.latest_version,
|
||||
version: info.latest_version,
|
||||
category: (info.category as Plugin['category']) ?? current.category,
|
||||
}))
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
>
|
||||
<RecommendCardFace card={card} />
|
||||
</button>
|
||||
<MarketplaceDetailDialog
|
||||
isInstalled={Boolean(installedInfo?.[plugin.plugin_id])}
|
||||
open={open}
|
||||
plugin={plugin}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendingCard({
|
||||
banner,
|
||||
card,
|
||||
isMarketplacePlatform,
|
||||
page,
|
||||
}: {
|
||||
banner: BannerRecommend
|
||||
card: BannerRecommendCard
|
||||
isMarketplacePlatform: boolean
|
||||
page: MarketplaceBannerPage
|
||||
}) {
|
||||
const embeddedPlugin = isMarketplacePlatform ? null : pluginFromRecommendCard(card)
|
||||
if (embeddedPlugin) {
|
||||
return (
|
||||
<EmbeddedRecommendPluginCard
|
||||
banner={banner}
|
||||
card={card}
|
||||
initialPlugin={embeddedPlugin}
|
||||
page={page}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const href = getCardHref(card, isMarketplacePlatform)
|
||||
if (!href) return null
|
||||
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={opensInNewTab ? '_blank' : undefined}
|
||||
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
|
||||
aria-label={card.display_name}
|
||||
onClick={() => {
|
||||
trackRecommendCardClick(banner, card, page, href)
|
||||
}}
|
||||
className={recommendCardClassName}
|
||||
>
|
||||
<RecommendCardFace card={card} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import List from '../index'
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: {} }),
|
||||
}))
|
||||
|
||||
const plugins = Array.from({ length: 5 }, (_, index) => ({
|
||||
plugin_id: `publisher/plugin-${index}`,
|
||||
org: 'publisher',
|
||||
name: `Plugin ${index + 1}`,
|
||||
})) as Plugin[]
|
||||
|
||||
describe('Marketplace search result layout', () => {
|
||||
// Native grid layout determines whether the result cards remain readable;
|
||||
// happy-dom cannot reproduce the four 75px columns seen on mobile.
|
||||
it.each([
|
||||
{ viewportWidth: 390, columns: 1 },
|
||||
{ viewportWidth: 1280, columns: 4 },
|
||||
])('keeps readable cards at $viewportWidth px', async ({ viewportWidth, columns }) => {
|
||||
await page.viewport(viewportWidth, 844)
|
||||
const screen = await render(
|
||||
<div style={{ width: viewportWidth - 40 }}>
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
cardRender={(plugin) => (
|
||||
<a key={plugin.plugin_id} href={`/plugin/${plugin.plugin_id}`}>
|
||||
{plugin.name}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const first = screen.getByRole('link', { name: 'Plugin 1' }).element().getBoundingClientRect()
|
||||
const nextRow = screen
|
||||
.getByRole('link', { name: `Plugin ${columns + 1}` })
|
||||
.element()
|
||||
.getBoundingClientRect()
|
||||
|
||||
expect(first.width).toBeGreaterThanOrEqual(250)
|
||||
expect(nextRow.top).toBeGreaterThanOrEqual(first.bottom)
|
||||
if (columns > 1) {
|
||||
const lastInRow = screen
|
||||
.getByRole('link', { name: `Plugin ${columns}` })
|
||||
.element()
|
||||
.getBoundingClientRect()
|
||||
expect(lastInRow.top).toBe(first.top)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use
|
||||
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
@ -77,7 +78,7 @@ const List = ({
|
||||
/>
|
||||
)}
|
||||
{plugins && !!plugins.length && (
|
||||
<div className={cn('grid grid-cols-4 gap-3', cardContainerClassName)}>
|
||||
<div className={cn(GRID_CLASS, cardContainerClassName)}>
|
||||
{plugins.map((plugin) => {
|
||||
if (cardRender) return cardRender(plugin)
|
||||
|
||||
|
||||
@ -26,7 +26,9 @@ export const getMarketplacePluginsInfiniteQueryOptions = (
|
||||
// the grid unmounts, the container collapses, and the scroll position jumps —
|
||||
// the "jitter" the Marketplace search is reported for. Consumers show a
|
||||
// quiet pending state off `isPlaceholderData` instead.
|
||||
placeholderData: keepPreviousData,
|
||||
// Returning to collections disables search; keeping its placeholder then
|
||||
// leaves the last category visible and permanently marked as refreshing.
|
||||
placeholderData: queryParams === undefined ? undefined : keepPreviousData,
|
||||
// Matches the autocomplete queries. Now that the fetcher propagates
|
||||
// failures, react-query's default of 3 retries would hold isFetching true
|
||||
// through ~7s of backoff — indistinguishable from a hang. Failing fast and
|
||||
|
||||
Loading…
Reference in New Issue
Block a user