feat: implement enhanced sorting and category management in marketplace components for improved user experience

This commit is contained in:
yessenia
2026-02-11 12:15:00 +08:00
parent 36f42ec0a9
commit 7b41fc4d64
15 changed files with 351 additions and 196 deletions

View File

@ -2,19 +2,30 @@ import type { SearchTab } from './search-params'
import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { CATEGORY_ALL, DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { useCallback, useMemo } from 'react'
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
export function useMarketplaceSort() {
return useAtom(marketplaceSortAtom)
const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
export function useMarketplacePluginSort() {
return useAtom(marketplacePluginSortAtom)
}
export function useMarketplaceSortValue() {
return useAtomValue(marketplaceSortAtom)
export function useMarketplacePluginSortValue() {
return useAtomValue(marketplacePluginSortAtom)
}
export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom)
export function useSetMarketplacePluginSort() {
return useSetAtom(marketplacePluginSortAtom)
}
const marketplaceTemplateSortAtom = atom<PluginsSort>(DEFAULT_TEMPLATE_SORT)
export function useMarketplaceTemplateSort() {
return useAtom(marketplaceTemplateSortAtom)
}
export function useMarketplaceTemplateSortValue() {
return useAtomValue(marketplaceTemplateSortAtom)
}
export function useSetMarketplaceTemplateSort() {
return useSetAtom(marketplaceTemplateSortAtom)
}
export function useSearchText() {
@ -64,22 +75,56 @@ export function useMarketplaceSearchMode() {
return isSearchMode
}
/**
* Returns the active sort state based on the current creationType.
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
*/
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
const [creationType] = useCreationType()
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
const isTemplates = creationType === CREATION_TYPE.templates
const sort = isTemplates ? templateSort : pluginSort
const setSort = useMemo(
() => isTemplates ? setTemplateSort : setPluginSort,
[isTemplates, setTemplateSort, setPluginSort],
)
return [sort, setSort]
}
export function useActiveSortValue(): PluginsSort {
const [creationType] = useCreationType()
const pluginSort = useAtomValue(marketplacePluginSortAtom)
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort
}
export function useMarketplaceMoreClick() {
const [, setQ] = useSearchText()
const [, setSearchTab] = useSearchTab()
const setSort = useSetAtom(marketplaceSortAtom)
const setPluginSort = useSetAtom(marketplacePluginSortAtom)
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
if (!searchParams)
return
setQ(searchParams?.query || '')
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
if (searchTab === 'templates') {
setTemplateSort({
sortBy: searchParams?.sort_by || DEFAULT_TEMPLATE_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_TEMPLATE_SORT.sortOrder,
})
}
else {
setPluginSort({
sortBy: searchParams?.sort_by || DEFAULT_PLUGIN_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_PLUGIN_SORT.sortOrder,
})
}
setSearchMode(true)
if (searchTab)
setSearchTab(searchTab)
}, [setQ, setSearchTab, setSort, setSearchMode])
}, [setQ, setSearchTab, setPluginSort, setTemplateSort, setSearchMode])
}

View File

@ -0,0 +1,67 @@
'use client'
import type { ActivePluginType, ActiveTemplateCategory } from '../constants'
import { useTranslation } from '#i18n'
import { PLUGIN_TYPE_SEARCH_MAP, TEMPLATE_CATEGORY_MAP } from '../constants'
/**
* Returns a getter that translates a plugin category value to its display text.
* Pass `allAsAllTypes = true` to use "All types" instead of "All" for the `all` category
* (e.g. hero variant in category switch).
*/
export function usePluginCategoryText() {
const { t } = useTranslation()
return (category: ActivePluginType, allAsAllTypes = false): string => {
switch (category) {
case PLUGIN_TYPE_SEARCH_MAP.model:
return t('category.models', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.tool:
return t('category.tools', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.datasource:
return t('category.datasources', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.trigger:
return t('category.triggers', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.agent:
return t('category.agents', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.extension:
return t('category.extensions', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.bundle:
return t('category.bundles', { ns: 'plugin' })
case PLUGIN_TYPE_SEARCH_MAP.all:
default:
return allAsAllTypes
? t('category.allTypes', { ns: 'plugin' })
: t('category.all', { ns: 'plugin' })
}
}
}
/**
* Returns a getter that translates a template category value to its display text.
*/
export function useTemplateCategoryText() {
const { t } = useTranslation()
return (category: ActiveTemplateCategory): string => {
switch (category) {
case TEMPLATE_CATEGORY_MAP.marketing:
return t('marketplace.templateCategory.marketing', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.sales:
return t('marketplace.templateCategory.sales', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.support:
return t('marketplace.templateCategory.support', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.operations:
return t('marketplace.templateCategory.operations', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.it:
return t('marketplace.templateCategory.it', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.knowledge:
return t('marketplace.templateCategory.knowledge', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.design:
return t('marketplace.templateCategory.design', { ns: 'plugin' })
case TEMPLATE_CATEGORY_MAP.all:
default:
return t('marketplace.templateCategory.all', { ns: 'plugin' })
}
}
}

View File

@ -2,13 +2,13 @@
import type { ActivePluginType } from '../constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArchive2Line } from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
import { usePluginCategoryText } from './category-text'
import { CommonCategorySwitch } from './common'
import HeroTagsFilter from './hero-tags-filter'
@ -17,6 +17,17 @@ type PluginTypeSwitchProps = {
variant?: 'default' | 'hero'
}
const categoryValues = [
PLUGIN_TYPE_SEARCH_MAP.all,
PLUGIN_TYPE_SEARCH_MAP.model,
PLUGIN_TYPE_SEARCH_MAP.tool,
PLUGIN_TYPE_SEARCH_MAP.datasource,
PLUGIN_TYPE_SEARCH_MAP.trigger,
PLUGIN_TYPE_SEARCH_MAP.agent,
PLUGIN_TYPE_SEARCH_MAP.extension,
PLUGIN_TYPE_SEARCH_MAP.bundle,
] as const
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
@ -30,55 +41,18 @@ export const PluginCategorySwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
const setSearchMode = useSetAtom(searchModeAtom)
const getPluginCategoryText = usePluginCategoryText()
const isHeroVariant = variant === 'hero'
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle, isHeroVariant),
},
]
const options = categoryValues.map(value => ({
value,
text: getPluginCategoryText(value, isHeroVariant),
icon: getTypeIcon(value, isHeroVariant),
}))
const handleChange = (value: string) => {
handleActivePluginCategoryChange(value)

View File

@ -1,9 +1,9 @@
'use client'
import { useTranslation } from '#i18n'
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
import { useActiveTemplateCategory } from '../atoms'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
import { useTemplateCategoryText } from './category-text'
import { CommonCategorySwitch } from './common'
type TemplateCategorySwitchProps = {
@ -11,57 +11,31 @@ type TemplateCategorySwitchProps = {
variant?: 'default' | 'hero'
}
const categoryValues = [
CATEGORY_ALL,
TEMPLATE_CATEGORY_MAP.marketing,
TEMPLATE_CATEGORY_MAP.sales,
TEMPLATE_CATEGORY_MAP.support,
TEMPLATE_CATEGORY_MAP.operations,
TEMPLATE_CATEGORY_MAP.it,
TEMPLATE_CATEGORY_MAP.knowledge,
TEMPLATE_CATEGORY_MAP.design,
] as const
export const TemplateCategorySwitch = ({
className,
variant = 'default',
}: TemplateCategorySwitchProps) => {
const { t } = useTranslation()
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
const getTemplateCategoryText = useTemplateCategoryText()
const isHeroVariant = variant === 'hero'
const options = [
{
value: CATEGORY_ALL,
text: t('marketplace.templateCategory.all', { ns: 'plugin' }),
icon: isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
},
{
value: TEMPLATE_CATEGORY_MAP.marketing,
text: t('marketplace.templateCategory.marketing', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.sales,
text: t('marketplace.templateCategory.sales', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.support,
text: t('marketplace.templateCategory.support', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.operations,
text: t('marketplace.templateCategory.operations', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.it,
text: t('marketplace.templateCategory.it', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.knowledge,
text: t('marketplace.templateCategory.knowledge', { ns: 'plugin' }),
icon: null,
},
{
value: TEMPLATE_CATEGORY_MAP.design,
text: t('marketplace.templateCategory.design', { ns: 'plugin' }),
icon: null,
},
]
const options = categoryValues.map(value => ({
value,
text: getTemplateCategoryText(value),
icon: value === CATEGORY_ALL && isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
}))
return (
<CommonCategorySwitch

View File

@ -1,6 +1,6 @@
import { PluginCategoryEnum } from '../types'
export const DEFAULT_SORT = {
export const DEFAULT_PLUGIN_SORT = {
sortBy: 'install_count',
sortOrder: 'DESC',
}

View File

@ -9,7 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
// ================================
// Note: Import after mocks are set up
import { DEFAULT_SORT, DEFAULT_TEMPLATE_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
import { DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
import {
getFormattedPlugin,
getPluginCondition,
@ -406,20 +406,20 @@ const createMockCollection = (overrides?: Partial<PluginCollection>): PluginColl
// Constants Tests
// ================================
describe('constants', () => {
describe('DEFAULT_SORT', () => {
describe('DEFAULT_PLUGIN_SORT', () => {
it('should have correct default sort values', () => {
expect(DEFAULT_SORT).toEqual({
expect(DEFAULT_PLUGIN_SORT).toEqual({
sortBy: 'install_count',
sortOrder: 'DESC',
})
})
it('should be immutable at runtime', () => {
const originalSortBy = DEFAULT_SORT.sortBy
const originalSortOrder = DEFAULT_SORT.sortOrder
const originalSortBy = DEFAULT_PLUGIN_SORT.sortBy
const originalSortOrder = DEFAULT_PLUGIN_SORT.sortOrder
expect(DEFAULT_SORT.sortBy).toBe(originalSortBy)
expect(DEFAULT_SORT.sortOrder).toBe(originalSortOrder)
expect(DEFAULT_PLUGIN_SORT.sortBy).toBe(originalSortBy)
expect(DEFAULT_PLUGIN_SORT.sortOrder).toBe(originalSortOrder)
})
})

View File

@ -0,0 +1,77 @@
'use client'
import { useTranslation } from '#i18n'
import {
useActivePluginCategory,
useActiveTemplateCategory,
useFilterPluginTags,
} from '../atoms'
import { usePluginCategoryText, useTemplateCategoryText } from '../category-switch/category-text'
import {
CATEGORY_ALL,
TEMPLATE_CATEGORY_MAP,
} from '../constants'
import SortDropdown from '../sort-dropdown'
type ListTopInfoProps = {
variant: 'plugins' | 'templates'
}
const ListTopInfo = ({ variant }: ListTopInfoProps) => {
const { t } = useTranslation()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()
const [activeTemplateCategory] = useActiveTemplateCategory()
const getPluginCategoryText = usePluginCategoryText()
const getTemplateCategoryText = useTemplateCategoryText()
const hasTags = variant === 'plugins' && filterPluginTags.length > 0
if (hasTags) {
return (
<div className="mb-4 flex items-center justify-between pt-3">
<p className="title-xl-semi-bold text-text-primary">
{t('marketplace.listTopInfo.tagsTitle', { ns: 'plugin' })}
</p>
<SortDropdown />
</div>
)
}
const isPlugins = variant === 'plugins'
const isAllCategory = isPlugins
? activePluginCategory === CATEGORY_ALL
: activeTemplateCategory === TEMPLATE_CATEGORY_MAP.all
const categoryText = isPlugins
? getPluginCategoryText(activePluginCategory)
: getTemplateCategoryText(activeTemplateCategory)
const title = isPlugins
? isAllCategory
? t('marketplace.listTopInfo.pluginsTitleAll', { ns: 'plugin' })
: t('marketplace.listTopInfo.pluginsTitleByCategory', { ns: 'plugin', category: categoryText })
: isAllCategory
? t('marketplace.listTopInfo.templatesTitleAll', { ns: 'plugin' })
: t('marketplace.listTopInfo.templatesTitleByCategory', { ns: 'plugin', category: categoryText })
const subtitleKey = isPlugins
? 'marketplace.listTopInfo.pluginsSubtitle'
: 'marketplace.listTopInfo.templatesSubtitle'
return (
<div className="mb-4 flex items-center justify-between pt-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="title-xl-semi-bold truncate text-text-primary">
{title}
</p>
<p className="system-xs-regular truncate text-text-tertiary">
{t(subtitleKey, { ns: 'plugin' })}
</p>
</div>
<SortDropdown />
</div>
)
}
export default ListTopInfo

View File

@ -3,6 +3,7 @@
import Loading from '@/app/components/base/loading'
import { isPluginsData, useMarketplaceData } from '../state'
import FlatList from './flat-list'
import ListTopInfo from './list-top-info'
import ListWithCollection from './list-with-collection'
type ListWrapperProps = {
@ -17,7 +18,12 @@ const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
if (isPluginsData(marketplaceData)) {
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
return plugins !== undefined
? <FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
? (
<>
<ListTopInfo variant="plugins" />
<FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
</>
)
: (
<ListWithCollection
variant="plugins"
@ -30,7 +36,12 @@ const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
return templates !== undefined
? <FlatList variant="templates" items={templates} />
? (
<>
<ListTopInfo variant="templates" />
<FlatList variant="templates" items={templates} />
</>
)
: (
<ListWithCollection
variant="templates"

View File

@ -81,7 +81,12 @@ vi.mock('../atoms', () => ({
useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
useMarketplaceSortValue: () => mockSortValue,
useMarketplacePluginSortValue: () => mockSortValue,
useMarketplaceTemplateSortValue: () => ({ sortBy: 'usage_count', sortOrder: 'DESC' }),
useActiveSort: () => [mockSortValue, vi.fn()],
useActiveSortValue: () => mockSortValue,
useCreationType: () => ['plugins', vi.fn()],
useSearchTab: () => ['', vi.fn()],
searchModeAtom: {},
}))

View File

@ -8,7 +8,7 @@ import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import Loading from '@/app/components/base/loading'
import SegmentedControl from '@/app/components/base/segmented-control'
import { useMarketplaceSortValue, useSearchTab, useSearchText } from '../atoms'
import { useMarketplacePluginSortValue, useMarketplaceTemplateSortValue, useSearchTab, useSearchText } from '../atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import Empty from '../empty'
import { useMarketplaceContainerScroll } from '../hooks'
@ -23,17 +23,12 @@ const PAGE_SIZE = 40
const ALL_TAB_PREVIEW_SIZE = 8
const ZERO_WIDTH_SPACE = '\u200B'
type SortValue = { sortBy: string, sortOrder: string }
// type SortValue = { sortBy: string, sortOrder: string }
function mapSortForTemplates(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
function mapSortForCreators(sort: SortValue): { sort_by: string, sort_order: string } {
const sortBy = sort.sortBy === 'install_count' ? 'created_at' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { sort_by: sortBy, sort_order: sort.sortOrder }
}
// function mapSortForCreators(sort: SortValue): { sort_by: string, sort_order: string } {
// const sortBy = sort.sortBy === 'install_count' ? 'created_at' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
// return { sort_by: sortBy, sort_order: sort.sortOrder }
// }
const SearchPage = () => {
const { t } = useTranslation()
@ -41,7 +36,8 @@ const SearchPage = () => {
const debouncedQuery = useDebounce(searchText, { wait: 500 })
const [searchTabParam, setSearchTab] = useSearchTab()
const searchTab = (searchTabParam || 'all') as SearchTab
const sort = useMarketplaceSortValue()
const pluginSort = useMarketplacePluginSortValue()
const templateSort = useMarketplaceTemplateSortValue()
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
@ -52,35 +48,33 @@ const SearchPage = () => {
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
sort_by: pluginSort.sortBy,
sort_order: pluginSort.sortOrder,
type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all),
} as PluginsSearchParams
}, [hasQuery, query, searchTab, sort])
}, [hasQuery, query, searchTab, pluginSort])
const templatesParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForTemplates(sort)
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by,
sort_order,
sort_by: templateSort.sortBy,
sort_order: templateSort.sortOrder,
}
}, [hasQuery, query, searchTab, sort])
}, [hasQuery, query, searchTab, templateSort])
const creatorsParams = useMemo(() => {
if (!hasQuery)
return undefined
const { sort_by, sort_order } = mapSortForCreators(sort)
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by,
sort_order,
// sort_by,
// sort_order,
}
}, [hasQuery, query, searchTab, sort])
}, [hasQuery, query, searchTab])
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
@ -198,32 +192,17 @@ const SearchPage = () => {
</div>
)
const renderPluginsTab = () => {
if (plugins.length === 0 && !pluginsQuery.isLoading)
return <Empty />
const renderTab = <T,>(
items: T[],
isItemLoading: boolean,
renderSection: (items: T[]) => React.ReactNode,
emptyText?: string,
) => {
if (items.length === 0 && !isItemLoading)
return <Empty text={emptyText} />
return (
<div className="py-4">
{renderPluginsSection(plugins)}
</div>
)
}
const renderTemplatesTab = () => {
if (templates.length === 0 && !templatesQuery.isLoading)
return <Empty text={t('marketplace.noTemplateFound', { ns: 'plugin' })} />
return (
<div className="py-4">
{renderTemplatesSection(templates)}
</div>
)
}
const renderCreatorsTab = () => {
if (creators.length === 0 && !creatorsQuery.isLoading)
return <Empty text={t('marketplace.noCreatorFound', { ns: 'plugin' })} />
return (
<div className="py-4">
{renderCreatorsSection(creators)}
{renderSection(items)}
</div>
)
}
@ -241,7 +220,7 @@ const SearchPage = () => {
onChange={v => setSearchTab(v as SearchTab)}
options={tabOptions}
/>
<SortDropdown />
{(searchTab === 'templates' || searchTab === 'plugins') && <SortDropdown />}
</div>
{isLoading && (
@ -253,9 +232,9 @@ const SearchPage = () => {
{!isLoading && (
<>
{searchTab === 'all' && renderAllTab()}
{searchTab === 'plugins' && renderPluginsTab()}
{searchTab === 'templates' && renderTemplatesTab()}
{searchTab === 'creators' && renderCreatorsTab()}
{searchTab === 'plugins' && renderTab(plugins, pluginsQuery.isLoading, renderPluginsSection)}
{searchTab === 'templates' && renderTab(templates, templatesQuery.isLoading, renderTemplatesSection, t('marketplace.noTemplateFound', { ns: 'plugin' }))}
{searchTab === 'creators' && renderTab(creators, creatorsQuery.isLoading, renderCreatorsSection, t('marketplace.noCreatorFound', { ns: 'plugin' }))}
</>
)}

View File

@ -30,9 +30,15 @@ vi.mock('#i18n', () => ({
// Mock marketplace atoms with controllable values
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn()
let mockCreationType = 'plugins'
vi.mock('../atoms', () => ({
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
useActiveSort: () => [mockSort, mockHandleSortChange],
useCreationType: () => [mockCreationType, vi.fn()],
}))
vi.mock('../search-params', () => ({
CREATION_TYPE: { plugins: 'plugins', templates: 'templates' },
}))
// Mock portal component with controllable open state
@ -91,6 +97,7 @@ describe('SortDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
mockCreationType = 'plugins'
mockPortalOpenState = false
})

View File

@ -10,33 +10,36 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useMarketplaceSort } from '../atoms'
import { useActiveSort, useCreationType } from '../atoms'
import { CREATION_TYPE } from '../search-params'
const PLUGIN_SORT_OPTIONS = [
{ value: 'install_count', order: 'DESC', labelKey: 'marketplace.sortOption.mostPopular' },
{ value: 'version_updated_at', order: 'DESC', labelKey: 'marketplace.sortOption.recentlyUpdated' },
{ value: 'created_at', order: 'DESC', labelKey: 'marketplace.sortOption.newlyReleased' },
{ value: 'created_at', order: 'ASC', labelKey: 'marketplace.sortOption.firstReleased' },
] as const
const TEMPLATE_SORT_OPTIONS = [
{ value: 'usage_count', order: 'DESC', labelKey: 'marketplace.sortOption.mostPopular' },
{ value: 'updated_at', order: 'DESC', labelKey: 'marketplace.sortOption.recentlyUpdated' },
{ value: 'created_at', order: 'DESC', labelKey: 'marketplace.sortOption.newlyReleased' },
{ value: 'created_at', order: 'ASC', labelKey: 'marketplace.sortOption.firstReleased' },
] as const
const SortDropdown = () => {
const { t } = useTranslation()
const options = [
{
value: 'install_count',
order: 'DESC',
text: t('marketplace.sortOption.mostPopular', { ns: 'plugin' }),
},
{
value: 'version_updated_at',
order: 'DESC',
text: t('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' }),
},
{
value: 'created_at',
order: 'DESC',
text: t('marketplace.sortOption.newlyReleased', { ns: 'plugin' }),
},
{
value: 'created_at',
order: 'ASC',
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
},
]
const [sort, handleSortChange] = useMarketplaceSort()
const [creationType] = useCreationType()
const isTemplates = creationType === CREATION_TYPE.templates
const rawOptions = isTemplates ? TEMPLATE_SORT_OPTIONS : PLUGIN_SORT_OPTIONS
const options = rawOptions.map(opt => ({
value: opt.value,
order: opt.order,
text: t(opt.labelKey, { ns: 'plugin' }),
}))
const [sort, handleSortChange] = useActiveSort()
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]

View File

@ -1,7 +1,7 @@
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchText } from './atoms'
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useMarketplacePluginSortValue, useMarketplaceSearchMode, useMarketplaceTemplateSortValue, useSearchText } from './atoms'
import { CATEGORY_ALL } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
@ -29,7 +29,7 @@ export function usePluginsMarketplaceData(enabled = true) {
{ enabled },
)
const sort = useMarketplaceSortValue()
const sort = useMarketplacePluginSortValue()
const isSearchMode = useMarketplaceSearchMode()
const queryParams = useMemo((): PluginsSearchParams | undefined => {
if (!isSearchMode)
@ -79,8 +79,8 @@ export function useTemplatesMarketplaceData(enabled = true) {
// Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
// Sort value
const sort = useMarketplaceSortValue()
// Template-specific sort value (independent from plugin sort)
const sort = useMarketplaceTemplateSortValue()
// Search mode: when there's search text or non-default category
const isSearchMode = useMarketplaceSearchMode()
@ -89,11 +89,10 @@ export function useTemplatesMarketplaceData(enabled = true) {
const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode)
return undefined
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return {
query: searchText,
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
sort_by: sortBy,
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
}
}, [isSearchMode, searchText, activeTemplateCategory, sort])

View File

@ -197,6 +197,13 @@
"marketplace.empower": "Empower your AI development",
"marketplace.featured": "Featured",
"marketplace.installs": "installs",
"marketplace.listTopInfo.pluginsSubtitle": "Plugins designed to speed up your coding and AI development",
"marketplace.listTopInfo.pluginsTitleAll": "All plugins",
"marketplace.listTopInfo.pluginsTitleByCategory": "All {{category}} plugins",
"marketplace.listTopInfo.tagsTitle": "Showing results filtered by tags",
"marketplace.listTopInfo.templatesSubtitle": "Workflows designed to speed up your coding and AI development",
"marketplace.listTopInfo.templatesTitleAll": "All templates",
"marketplace.listTopInfo.templatesTitleByCategory": "All {{category}} templates",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noCreatorFound": "No creator found",
"marketplace.noPluginFound": "No plugin found",

View File

@ -197,6 +197,13 @@
"marketplace.empower": "助力您的 AI 开发",
"marketplace.featured": "精选",
"marketplace.installs": "次安装",
"marketplace.listTopInfo.pluginsSubtitle": "帮助你更快进行编码与 AI 开发的插件",
"marketplace.listTopInfo.pluginsTitleAll": "全部插件",
"marketplace.listTopInfo.pluginsTitleByCategory": "全部{{category}}插件",
"marketplace.listTopInfo.tagsTitle": "显示按标签筛选的结果",
"marketplace.listTopInfo.templatesSubtitle": "帮助你更快进行编码与 AI 开发的工作流",
"marketplace.listTopInfo.templatesTitleAll": "全部模板",
"marketplace.listTopInfo.templatesTitleByCategory": "全部{{category}}模板",
"marketplace.moreFrom": "更多来自市场",
"marketplace.noCreatorFound": "未找到创作者",
"marketplace.noPluginFound": "未找到插件",