feat: implement carousel column layout and adjust grid display limit in marketplace components for improved visual organization

This commit is contained in:
yessenia 2026-02-12 00:48:05 +08:00
parent 26fe8c1cc5
commit 8108c21d5b
5 changed files with 131 additions and 31 deletions

View File

@ -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)]'

View File

@ -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<TItem>({
renderCard,
cardContainerClassName,
}: CarouselCollectionProps<TItem>) {
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 (
<Carousel

View File

@ -47,10 +47,12 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
vi.mock('../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
isPluginsData: (data: Record<string, unknown>) => '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<string, string | undefined>) =>
`/plugins/${plugin.org}/${plugin.name}`,
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
`/plugins/${plugin.org}/${plugin.name}`,
}))
vi.mock('../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils')>()
return {
...actual,
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
`/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<string, Plugin[]> = {
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<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
'collection-0': createMockPluginList(5),
}
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
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<string, Plugin[]> = {
'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<string, Plugin[]> = {
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<string, Plugin[]> = {
'collection-0': createMockPluginList(8),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
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<string, Plugin[]> = {
'collection-0': createMockPluginList(3),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
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<string, Plugin[]> = {
'collection-0': createMockPluginList(4),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
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(<ListWrapper />)
@ -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<string, Plugin[]> = {
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(

View File

@ -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}
/>
)

View File

@ -32,6 +32,26 @@ export function getItemKeyByField<T>(item: T, field: keyof T): string {
return String((item as Record<string, unknown>)[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<T>(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`