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 type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs' import { useQueryState } from 'nuqs'
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { CATEGORY_ALL, DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params' import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT) const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
export function useMarketplaceSort() { export function useMarketplacePluginSort() {
return useAtom(marketplaceSortAtom) return useAtom(marketplacePluginSortAtom)
} }
export function useMarketplaceSortValue() { export function useMarketplacePluginSortValue() {
return useAtomValue(marketplaceSortAtom) return useAtomValue(marketplacePluginSortAtom)
} }
export function useSetMarketplaceSort() { export function useSetMarketplacePluginSort() {
return useSetAtom(marketplaceSortAtom) 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() { export function useSearchText() {
@ -64,22 +75,56 @@ export function useMarketplaceSearchMode() {
return isSearchMode 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() { export function useMarketplaceMoreClick() {
const [, setQ] = useSearchText() const [, setQ] = useSearchText()
const [, setSearchTab] = useSearchTab() const [, setSearchTab] = useSearchTab()
const setSort = useSetAtom(marketplaceSortAtom) const setPluginSort = useSetAtom(marketplacePluginSortAtom)
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
const setSearchMode = useSetAtom(searchModeAtom) const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => { return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
if (!searchParams) if (!searchParams)
return return
setQ(searchParams?.query || '') setQ(searchParams?.query || '')
setSort({ if (searchTab === 'templates') {
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, setTemplateSort({
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, 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) setSearchMode(true)
if (searchTab) if (searchTab)
setSearchTab(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 { ActivePluginType } from '../constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types' import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArchive2Line } from '@remixicon/react' import { RiArchive2Line } from '@remixicon/react'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { Plugin } from '@/app/components/base/icons/src/vender/plugin' import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms' import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons' import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
import { usePluginCategoryText } from './category-text'
import { CommonCategorySwitch } from './common' import { CommonCategorySwitch } from './common'
import HeroTagsFilter from './hero-tags-filter' import HeroTagsFilter from './hero-tags-filter'
@ -17,6 +17,17 @@ type PluginTypeSwitchProps = {
variant?: 'default' | 'hero' 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) => { const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
if (value === PLUGIN_TYPE_SEARCH_MAP.all) if (value === PLUGIN_TYPE_SEARCH_MAP.all)
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
@ -30,55 +41,18 @@ export const PluginCategorySwitch = ({
className, className,
variant = 'default', variant = 'default',
}: PluginTypeSwitchProps) => { }: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory() const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags() const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
const setSearchMode = useSetAtom(searchModeAtom) const setSearchMode = useSetAtom(searchModeAtom)
const getPluginCategoryText = usePluginCategoryText()
const isHeroVariant = variant === 'hero' const isHeroVariant = variant === 'hero'
const options = [ const options = categoryValues.map(value => ({
{ value,
value: PLUGIN_TYPE_SEARCH_MAP.all, text: getPluginCategoryText(value, isHeroVariant),
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }), icon: getTypeIcon(value, isHeroVariant),
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 handleChange = (value: string) => { const handleChange = (value: string) => {
handleActivePluginCategoryChange(value) handleActivePluginCategoryChange(value)

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
// ================================ // ================================
// Note: Import after mocks are set up // 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 { import {
getFormattedPlugin, getFormattedPlugin,
getPluginCondition, getPluginCondition,
@ -406,20 +406,20 @@ const createMockCollection = (overrides?: Partial<PluginCollection>): PluginColl
// Constants Tests // Constants Tests
// ================================ // ================================
describe('constants', () => { describe('constants', () => {
describe('DEFAULT_SORT', () => { describe('DEFAULT_PLUGIN_SORT', () => {
it('should have correct default sort values', () => { it('should have correct default sort values', () => {
expect(DEFAULT_SORT).toEqual({ expect(DEFAULT_PLUGIN_SORT).toEqual({
sortBy: 'install_count', sortBy: 'install_count',
sortOrder: 'DESC', sortOrder: 'DESC',
}) })
}) })
it('should be immutable at runtime', () => { it('should be immutable at runtime', () => {
const originalSortBy = DEFAULT_SORT.sortBy const originalSortBy = DEFAULT_PLUGIN_SORT.sortBy
const originalSortOrder = DEFAULT_SORT.sortOrder const originalSortOrder = DEFAULT_PLUGIN_SORT.sortOrder
expect(DEFAULT_SORT.sortBy).toBe(originalSortBy) expect(DEFAULT_PLUGIN_SORT.sortBy).toBe(originalSortBy)
expect(DEFAULT_SORT.sortOrder).toBe(originalSortOrder) 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 Loading from '@/app/components/base/loading'
import { isPluginsData, useMarketplaceData } from '../state' import { isPluginsData, useMarketplaceData } from '../state'
import FlatList from './flat-list' import FlatList from './flat-list'
import ListTopInfo from './list-top-info'
import ListWithCollection from './list-with-collection' import ListWithCollection from './list-with-collection'
type ListWrapperProps = { type ListWrapperProps = {
@ -17,7 +18,12 @@ const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
if (isPluginsData(marketplaceData)) { if (isPluginsData(marketplaceData)) {
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
return plugins !== undefined return plugins !== undefined
? <FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} /> ? (
<>
<ListTopInfo variant="plugins" />
<FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
</>
)
: ( : (
<ListWithCollection <ListWithCollection
variant="plugins" variant="plugins"
@ -30,7 +36,12 @@ const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
return templates !== undefined return templates !== undefined
? <FlatList variant="templates" items={templates} /> ? (
<>
<ListTopInfo variant="templates" />
<FlatList variant="templates" items={templates} />
</>
)
: ( : (
<ListWithCollection <ListWithCollection
variant="templates" variant="templates"

View File

@ -81,7 +81,12 @@ vi.mock('../atoms', () => ({
useSearchText: () => [mockSearchText, mockHandleSearchTextChange], useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()], 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: {}, searchModeAtom: {},
})) }))

View File

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

View File

@ -10,33 +10,36 @@ import {
PortalToFollowElemContent, PortalToFollowElemContent,
PortalToFollowElemTrigger, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } 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 SortDropdown = () => {
const { t } = useTranslation() const { t } = useTranslation()
const options = [ const [creationType] = useCreationType()
{ const isTemplates = creationType === CREATION_TYPE.templates
value: 'install_count',
order: 'DESC', const rawOptions = isTemplates ? TEMPLATE_SORT_OPTIONS : PLUGIN_SORT_OPTIONS
text: t('marketplace.sortOption.mostPopular', { ns: 'plugin' }), const options = rawOptions.map(opt => ({
}, value: opt.value,
{ order: opt.order,
value: 'version_updated_at', text: t(opt.labelKey, { ns: 'plugin' }),
order: 'DESC', }))
text: t('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' }),
}, const [sort, handleSortChange] = useActiveSort()
{
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 [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] 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 type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks' import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react' 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 { CATEGORY_ALL } from './constants'
import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
@ -29,7 +29,7 @@ export function usePluginsMarketplaceData(enabled = true) {
{ enabled }, { enabled },
) )
const sort = useMarketplaceSortValue() const sort = useMarketplacePluginSortValue()
const isSearchMode = useMarketplaceSearchMode() const isSearchMode = useMarketplaceSearchMode()
const queryParams = useMemo((): PluginsSearchParams | undefined => { const queryParams = useMemo((): PluginsSearchParams | undefined => {
if (!isSearchMode) if (!isSearchMode)
@ -79,8 +79,8 @@ export function useTemplatesMarketplaceData(enabled = true) {
// Template collections query (for non-search mode) // Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled }) const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
// Sort value // Template-specific sort value (independent from plugin sort)
const sort = useMarketplaceSortValue() const sort = useMarketplaceTemplateSortValue()
// Search mode: when there's search text or non-default category // Search mode: when there's search text or non-default category
const isSearchMode = useMarketplaceSearchMode() const isSearchMode = useMarketplaceSearchMode()
@ -89,11 +89,10 @@ export function useTemplatesMarketplaceData(enabled = true) {
const queryParams = useMemo((): TemplateSearchParams | undefined => { const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode) if (!isSearchMode)
return undefined return undefined
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
return { return {
query: searchText, query: searchText,
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory], categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
sort_by: sortBy, sort_by: sort.sortBy,
sort_order: sort.sortOrder, sort_order: sort.sortOrder,
} }
}, [isSearchMode, searchText, activeTemplateCategory, sort]) }, [isSearchMode, searchText, activeTemplateCategory, sort])

View File

@ -197,6 +197,13 @@
"marketplace.empower": "Empower your AI development", "marketplace.empower": "Empower your AI development",
"marketplace.featured": "Featured", "marketplace.featured": "Featured",
"marketplace.installs": "installs", "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.moreFrom": "More from Marketplace",
"marketplace.noCreatorFound": "No creator found", "marketplace.noCreatorFound": "No creator found",
"marketplace.noPluginFound": "No plugin found", "marketplace.noPluginFound": "No plugin found",

View File

@ -197,6 +197,13 @@
"marketplace.empower": "助力您的 AI 开发", "marketplace.empower": "助力您的 AI 开发",
"marketplace.featured": "精选", "marketplace.featured": "精选",
"marketplace.installs": "次安装", "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.moreFrom": "更多来自市场",
"marketplace.noCreatorFound": "未找到创作者", "marketplace.noCreatorFound": "未找到创作者",
"marketplace.noPluginFound": "未找到插件", "marketplace.noPluginFound": "未找到插件",