diff --git a/web/app/components/plugins/marketplace/list/collection-constants.ts b/web/app/components/plugins/marketplace/list/collection-constants.ts index 742d5a7cb3..e3a0b41d56 100644 --- a/web/app/components/plugins/marketplace/list/collection-constants.ts +++ b/web/app/components/plugins/marketplace/list/collection-constants.ts @@ -1,6 +1,6 @@ export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' -export const GRID_DISPLAY_LIMIT = 8 +export const GRID_DISPLAY_LIMIT = 4 export const CAROUSEL_COLUMN_CLASS = 'flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]' diff --git a/web/app/components/plugins/marketplace/list/collection-list.tsx b/web/app/components/plugins/marketplace/list/collection-list.tsx index 47c601c9b7..ea19ad44bb 100644 --- a/web/app/components/plugins/marketplace/list/collection-list.tsx +++ b/web/app/components/plugins/marketplace/list/collection-list.tsx @@ -10,7 +10,7 @@ import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' import { useMarketplaceMoreClick } from '../atoms' import Empty from '../empty' -import { getItemKeyByField } from '../utils' +import { buildCarouselColumns, getItemKeyByField } from '../utils' import Carousel from './carousel' import { CAROUSEL_COLUMN_CLASS, CAROUSEL_MAX_VISIBLE_COLUMNS, GRID_CLASS, GRID_DISPLAY_LIMIT } from './collection-constants' @@ -81,15 +81,7 @@ export function CarouselCollection({ renderCard, cardContainerClassName, }: CarouselCollectionProps) { - const useDoubleRow = items.length > CAROUSEL_MAX_VISIBLE_COLUMNS - const numColumns = useDoubleRow ? Math.ceil(items.length / 2) : items.length - const columns: TItem[][] = [] - for (let i = 0; i < numColumns; i++) { - const column: TItem[] = [items[i]] - if (useDoubleRow && i + numColumns < items.length) - column.push(items[i + numColumns]) - columns.push(column) - } + const columns = buildCarouselColumns(items, CAROUSEL_MAX_VISIBLE_COLUMNS) return ( { vi.mock('../state', () => ({ useMarketplaceData: () => mockMarketplaceData, + isPluginsData: (data: Record) => 'pluginCollections' in data, })) vi.mock('../atoms', () => ({ useMarketplaceMoreClick: () => mockMoreClick, + useMarketplaceSearchMode: () => false, })) // Mock useLocale context @@ -113,12 +115,16 @@ vi.mock('@/i18n-config/language', () => ({ })) // Mock marketplace utils -vi.mock('../utils', () => ({ - getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record) => - `/plugins/${plugin.org}/${plugin.name}`, - getPluginDetailLinkInMarketplace: (plugin: Plugin) => - `/plugins/${plugin.org}/${plugin.name}`, -})) +vi.mock('../utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record) => + `/plugins/${plugin.org}/${plugin.name}`, + getPluginDetailLinkInMarketplace: (plugin: Plugin) => + `/plugins/${plugin.org}/${plugin.name}`, + } +}) // Mock Card component vi.mock('@/app/components/plugins/card', () => ({ @@ -581,14 +587,14 @@ describe('ListWithCollection', () => { // View More Button Tests // ================================ describe('View More Button', () => { - it('should render View More button when collection is searchable', () => { + it('should render View More button when non-carousel collection is searchable and exceeds display limit', () => { const collections = [createMockCollection({ - name: 'partners', + name: 'searchable-collection', searchable: true, search_params: { query: 'test' }, })] const pluginsMap: Record = { - partners: createMockPluginList(1), + 'searchable-collection': createMockPluginList(5), } render( @@ -606,9 +612,31 @@ describe('ListWithCollection', () => { const collections = [createMockCollection({ name: 'collection-0', searchable: false, + search_params: undefined, })] const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), + 'collection-0': createMockPluginList(5), + } + + render( + , + ) + + expect(screen.queryByText('View More')).not.toBeInTheDocument() + }) + + it('should not render View More button when items do not exceed display limit', () => { + const collections = [createMockCollection({ + name: 'small-collection', + searchable: true, + search_params: { query: 'test' }, + })] + const pluginsMap: Record = { + 'small-collection': createMockPluginList(4), } render( @@ -625,12 +653,12 @@ describe('ListWithCollection', () => { it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ - name: 'partners', + name: 'clickable-collection', searchable: true, search_params: searchParams, })] const pluginsMap: Record = { - partners: createMockPluginList(1), + 'clickable-collection': createMockPluginList(5), } render( @@ -644,7 +672,66 @@ describe('ListWithCollection', () => { fireEvent.click(screen.getByText('View More')) expect(mockMoreClick).toHaveBeenCalledTimes(1) - expect(mockMoreClick).toHaveBeenCalledWith(searchParams) + expect(mockMoreClick).toHaveBeenCalledWith(searchParams, undefined) + }) + }) + + // ================================ + // Grid Display Limit Tests + // ================================ + describe('Grid Display Limit', () => { + it('should render at most 4 cards for non-carousel collections', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(8), + } + + const { container } = render( + , + ) + + const cards = container.querySelectorAll('[data-testid^="card-plugin-"]') + expect(cards.length).toBe(4) + }) + + it('should render all cards when count is within the display limit', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(3), + } + + const { container } = render( + , + ) + + const cards = container.querySelectorAll('[data-testid^="card-plugin-"]') + expect(cards.length).toBe(3) + }) + + it('should render exactly 4 cards when collection has exactly 4 items', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(4), + } + + const { container } = render( + , + ) + + const cards = container.querySelectorAll('[data-testid^="card-plugin-"]') + expect(cards.length).toBe(4) }) }) @@ -900,12 +987,12 @@ describe('ListWrapper', () => { it('should show View More button and call moreClick hook', () => { mockMarketplaceData.pluginCollections = [createMockCollection({ - name: 'partners', + name: 'wrapper-collection', searchable: true, search_params: { query: 'test' }, })] mockMarketplaceData.pluginCollectionPluginsMap = { - partners: createMockPluginList(1), + 'wrapper-collection': createMockPluginList(5), } render() @@ -1361,13 +1448,14 @@ describe('Accessibility', () => { expect(headings.length).toBeGreaterThan(0) }) - it('should have clickable View More button', () => { + it('should have clickable View More button', () => { const collections = [createMockCollection({ - name: 'partners', + name: 'accessible-collection', searchable: true, + search_params: { query: 'test' }, })] const pluginsMap: Record = { - partners: createMockPluginList(1), + 'accessible-collection': createMockPluginList(5), } render( @@ -1383,7 +1471,7 @@ describe('Accessibility', () => { expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer') }) - it('should have proper grid layout for cards', () => { + it('should have proper grid layout for cards', () => { const plugins = createMockPluginList(4) const { container } = render( diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 634e34e326..7f694559c9 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -56,7 +56,7 @@ const ListWithCollection = (props: ListWithCollectionProps) => { collectionItemsMap={collectionItemsMap} itemKeyField="plugin_id" renderCard={renderPluginCard} - carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners]} + carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners, CAROUSEL_COLLECTION_NAMES.featured]} cardContainerClassName={cardContainerClassName} /> ) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index dfc1f9b334..e7131e6283 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -32,6 +32,26 @@ export function getItemKeyByField(item: T, field: keyof T): string { return String((item as Record)[field as string]) } +/** + * Group a flat array into columns for a carousel grid layout. + * When the item count exceeds `maxVisibleColumns`, items are arranged in + * a two-row, column-first order with the first row always fully filled. + */ +export function buildCarouselColumns(items: T[], maxVisibleColumns: number): T[][] { + const useDoubleRow = items.length > maxVisibleColumns + const numColumns = useDoubleRow + ? Math.max(maxVisibleColumns, Math.ceil(items.length / 2)) + : items.length + const columns: T[][] = [] + for (let i = 0; i < numColumns; i++) { + const column: T[] = [items[i]] + if (useDoubleRow && i + numColumns < items.length) + column.push(items[i + numColumns]) + columns.push(column) + } + return columns +} + export const getPluginIconInMarketplace = (plugin: Plugin) => { if (plugin.type === 'bundle') return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`