feat: add unit tests for getValidatedPluginCategory and enhance search box functionality

This commit is contained in:
yessenia 2026-02-10 20:19:03 +08:00
parent b241122cf7
commit 56c5739e4e
11 changed files with 286 additions and 137 deletions

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { getValidatedPluginCategory } from './constants'
describe('getValidatedPluginCategory', () => {
it('returns agent-strategy when query value is agent-strategy', () => {
expect(getValidatedPluginCategory('agent-strategy')).toBe('agent-strategy')
})
it('returns valid category values unchanged', () => {
expect(getValidatedPluginCategory('model')).toBe('model')
expect(getValidatedPluginCategory('tool')).toBe('tool')
expect(getValidatedPluginCategory('bundle')).toBe('bundle')
})
it('falls back to all for invalid category values', () => {
expect(getValidatedPluginCategory('agent')).toBe('all')
expect(getValidatedPluginCategory('invalid-category')).toBe('all')
})
})

View File

@ -23,6 +23,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
type ValueOf<T> = T[keyof T]
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
const VALID_PLUGIN_CATEGORIES = new Set<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP))
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
[
@ -31,7 +32,6 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
],
)
export const TEMPLATE_CATEGORY_MAP = {
all: CATEGORY_ALL,
marketing: 'marketing',
@ -46,8 +46,10 @@ export const TEMPLATE_CATEGORY_MAP = {
export type ActiveTemplateCategory = typeof TEMPLATE_CATEGORY_MAP[keyof typeof TEMPLATE_CATEGORY_MAP]
export function getValidatedPluginCategory(category: string): ActivePluginType {
const key = (category in PLUGIN_TYPE_SEARCH_MAP ? category : CATEGORY_ALL) as keyof typeof PLUGIN_TYPE_SEARCH_MAP
return PLUGIN_TYPE_SEARCH_MAP[key]
if (VALID_PLUGIN_CATEGORIES.has(category as ActivePluginType))
return category as ActivePluginType
return CATEGORY_ALL
}
export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory {

View File

@ -1,4 +1,4 @@
import type { MarketplaceCollection } from './types'
import type { PluginCollection } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { act, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -12,9 +12,9 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
import {
getFormattedPlugin,
getMarketplaceListCondition,
getMarketplaceListFilterType,
getPluginCondition,
getPluginDetailLinkInMarketplace,
getPluginFilterType,
getPluginIconInMarketplace,
getPluginLinkInMarketplace,
} from './utils'
@ -386,7 +386,7 @@ const createMockPluginList = (count: number): Plugin[] =>
install_count: 1000 - i * 10,
}))
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
name: 'test-collection',
label: { 'en-US': 'Test Collection' },
description: { 'en-US': 'Test collection description' },
@ -540,57 +540,57 @@ describe('utils', () => {
})
})
describe('getMarketplaceListCondition', () => {
describe('getPluginCondition', () => {
it('should return category condition for tool', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
})
it('should return category condition for model', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
})
it('should return category condition for agent', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
})
it('should return category condition for datasource', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
})
it('should return category condition for trigger', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
})
it('should return endpoint category for extension', () => {
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
})
it('should return type condition for bundle', () => {
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
expect(getPluginCondition('bundle')).toBe('type=bundle')
})
it('should return empty string for all', () => {
expect(getMarketplaceListCondition('all')).toBe('')
expect(getPluginCondition('all')).toBe('')
})
it('should return empty string for unknown type', () => {
expect(getMarketplaceListCondition('unknown')).toBe('')
expect(getPluginCondition('unknown')).toBe('')
})
})
describe('getMarketplaceListFilterType', () => {
describe('getPluginFilterType', () => {
it('should return undefined for all', () => {
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
})
it('should return bundle for bundle', () => {
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
})
it('should return plugin for other categories', () => {
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
})
})
})

View File

@ -32,6 +32,7 @@ vi.mock('#i18n', () => ({
return translations[fullKey] || key
},
}),
useLocale: () => 'en-US',
}))
vi.mock('ahooks', () => ({
@ -648,6 +649,30 @@ describe('SearchBoxWrapper', () => {
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
})
it('should clear committed search when input is emptied and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
// Focus, type something, then clear and blur
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'test query' } })
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('')
})
it('should not clear committed search when input has content and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'still has text' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
})
})
describe('Translation', () => {

View File

@ -97,8 +97,13 @@ const SearchBoxWrapper = ({
setIsFocused(true)
}}
onBlur={() => {
if (!isHoveringDropdown)
if (!isHoveringDropdown) {
if (!draftSearch.trim()) {
handleSearchTextChange('')
setSearchMode(null)
}
setIsFocused(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter')

View File

@ -1,17 +1,24 @@
import type { Creator, Template } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import type { Locale } from '@/i18n-config/language'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightLine } from '@remixicon/react'
import { Fragment } from 'react'
import Loading from '@/app/components/base/loading'
import { useCategories } from '@/app/components/plugins/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
import { getMarketplaceUrl } from '@/utils/var'
const DROPDOWN_PANEL = 'w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
const ICON_BOX_BASE = 'flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
const SectionDivider = () => (
<div className="border-t border-divider-subtle" />
)
const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
<div className="p-1">
@ -25,24 +32,34 @@ const DropdownItem = ({ href, icon, children }: {
icon: React.ReactNode
children: React.ReactNode
}) => (
<a className="flex gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover" href={href}>
<a className="flex gap-1 rounded-lg py-1 pl-3 pr-1 hover:bg-state-base-hover" href={href}>
{icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 p-1">{children}</div>
</a>
)
const IconBox = ({ shape, className, children }: {
const IconBox = ({ shape, size = 'sm', className, style, children }: {
shape: 'rounded-lg' | 'rounded-full'
size?: 'sm' | 'md'
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) => (
<div className={`${ICON_BOX_BASE} ${shape} ${className ?? ''}`}>
<div
className={cn(
ICON_BOX_BASE,
shape,
size === 'sm' ? 'h-7 w-7' : 'h-8 w-8',
className,
)}
style={style}
>
{children}
</div>
)
const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => (
<div className="flex items-center gap-1.5 pt-0.5 text-text-tertiary">
<div className="flex items-center gap-1.5 pt-1 text-text-tertiary">
{items.filter(Boolean).map((item, i) => (
<Fragment key={i}>
{i > 0 && <span className="system-xs-regular">·</span>}
@ -70,11 +87,48 @@ const SearchDropdown = ({
isLoading = false,
}: SearchDropdownProps) => {
const { t } = useTranslation()
const locale = useLocale()
const getValueFromI18nObject = useRenderI18nObject()
const { categoriesMap } = useCategories(true)
const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0
// Collect rendered sections with dividers between them
const sections: React.ReactNode[] = []
if (templates.length > 0) {
sections.push(
<TemplatesSection
key="templates"
templates={templates}
locale={locale}
t={t}
/>,
)
}
if (plugins.length > 0) {
sections.push(
<PluginsSection
key="plugins"
plugins={plugins}
getValueFromI18nObject={getValueFromI18nObject}
categoriesMap={categoriesMap}
t={t}
/>,
)
}
if (creators.length > 0) {
sections.push(
<CreatorsSection
key="creators"
creators={creators}
t={t}
/>,
)
}
return (
<div className={DROPDOWN_PANEL}>
<div className="flex flex-col">
@ -84,104 +138,12 @@ const SearchDropdown = ({
</div>
)}
{plugins.length > 0 && (
<DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
{plugins.map((plugin) => {
const title = getValueFromI18nObject(plugin.label) || plugin.name
const description = getValueFromI18nObject(plugin.brief) || ''
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
const author = plugin.org || plugin.author || ''
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
const categoryNode = (
<div className="flex items-center gap-1">
{TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />}
<span>{categoryLabel}</span>
</div>
)
return (
<DropdownItem
key={`${plugin.org}/${plugin.name}`}
href={getPluginDetailLinkInMarketplace(plugin)}
icon={(
<IconBox shape="rounded-lg">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</IconBox>
)}
>
<div className="system-sm-medium truncate text-text-primary">{title}</div>
{!!description && (
<div className="system-xs-regular truncate text-text-tertiary">{description}</div>
)}
<ItemMeta
items={[
categoryNode,
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
installLabel,
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)}
{templates.length > 0 && (
<DropdownSection title={t('templates', { ns: 'plugin' })}>
{templates.map(template => (
<DropdownItem
key={template.template_id}
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
icon={(
<IconBox shape="rounded-lg" className="text-base">
{template.icon || '📄'}
</IconBox>
)}
>
<div className="system-sm-medium truncate text-text-primary">{template.name}</div>
<ItemMeta
items={[
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
...(template.tags.length > 0
? [<span className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
: []),
]}
/>
</DropdownItem>
))}
</DropdownSection>
)}
{creators.length > 0 && (
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
{creators.map(creator => (
<DropdownItem
key={creator.unique_handle}
href={getMarketplaceUrl(`/creators/${creator.unique_handle}`)}
icon={(
<IconBox shape="rounded-full">
<img
className="h-full w-full object-cover"
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={creator.display_name}
/>
</IconBox>
)}
>
<div className="flex items-center gap-1.5">
<span className="system-sm-medium truncate text-text-primary">{creator.display_name}</span>
<span className="system-xs-regular text-text-tertiary">
@
{creator.unique_handle}
</span>
</div>
{!!creator.description && (
<div className="system-xs-regular truncate text-text-tertiary">{creator.description}</div>
)}
</DropdownItem>
))}
</DropdownSection>
)}
{sections.map((section, i) => (
<Fragment key={i}>
{i > 0 && <SectionDivider />}
{section}
</Fragment>
))}
</div>
<div className="border-t border-divider-subtle p-1">
<button
@ -204,4 +166,136 @@ const SearchDropdown = ({
)
}
/* ---------- Templates Section ---------- */
function TemplatesSection({ templates, locale, t }: {
templates: Template[]
locale: Locale
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('templates', { ns: 'plugin' })}>
{templates.map((template) => {
const descriptionText = template.description[getLanguage(locale)] || template.description.en_US || ''
const iconBgStyle = template.icon_background
? { backgroundColor: template.icon_background }
: undefined
return (
<DropdownItem
key={template.template_id}
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg" style={iconBgStyle}>
<span className="text-xl leading-[1.2]">{template.icon || '📄'}</span>
</IconBox>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{template.name}</div>
{!!descriptionText && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{descriptionText}</div>
)}
<ItemMeta
items={[
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
...(template.tags.length > 0
? [<span key="tags" className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
: []),
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Plugins Section ---------- */
function PluginsSection({ plugins, getValueFromI18nObject, categoriesMap, t }: {
plugins: Plugin[]
getValueFromI18nObject: ReturnType<typeof useRenderI18nObject>
categoriesMap: Record<string, { label: string }>
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
{plugins.map((plugin) => {
const title = getValueFromI18nObject(plugin.label) || plugin.name
const description = getValueFromI18nObject(plugin.brief) || ''
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
const author = plugin.org || plugin.author || ''
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
const categoryNode = (
<div className="flex items-center gap-1">
{TypeIcon && <TypeIcon className="h-[14px] w-[14px] text-text-tertiary" />}
<span className="system-xs-regular">{categoryLabel}</span>
</div>
)
return (
<DropdownItem
key={`${plugin.org}/${plugin.name}`}
href={getPluginDetailLinkInMarketplace(plugin)}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</IconBox>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{title}</div>
{!!description && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{description}</div>
)}
<ItemMeta
items={[
categoryNode,
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
installLabel,
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Creators Section ---------- */
function CreatorsSection({ creators, t }: {
creators: Creator[]
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
{creators.map(creator => (
<a
key={creator.unique_handle}
className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover"
href={getMarketplaceUrl(`/creators/${creator.unique_handle}`)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-[0.5px] border-divider-regular">
<img
className="h-full w-full object-cover"
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={creator.display_name}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-px">
<div className="system-md-medium truncate text-text-primary">{creator.display_name}</div>
<div className="system-xs-regular truncate text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</a>
))}
</DropdownSection>
)
}
export default SearchDropdown

View File

@ -76,6 +76,7 @@ export type Template = {
name: string
description: Record<string, string>
icon: string
icon_background?: string
tags: string[]
author: string
created_at: string
@ -207,6 +208,7 @@ export type UnifiedTemplateItem = {
index_id: string
template_name: string
icon: string
icon_background?: string
icon_file_key: string
categories: string[]
overview: string

View File

@ -142,6 +142,7 @@ export function mapTemplateDetailToTemplate(template: TemplateDetail): Template
zh_Hans: descriptionText,
},
icon: template.icon || '',
icon_background: template.icon_background || undefined,
tags: template.categories || [],
author: template.publisher_unique_handle || template.creator_email || '',
created_at: template.created_at,
@ -448,6 +449,7 @@ export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Templat
zh_Hans: descriptionText,
},
icon: item.icon || '',
icon_background: item.icon_background || undefined,
tags: item.categories || [],
author: item.publisher_handle || '',
created_at: item.created_at,

View File

@ -4,7 +4,7 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { getMarketplaceUrl } from '@/utils/var'
@ -289,7 +289,7 @@ describe('useMarketplace', () => {
await waitFor(() => {
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
condition: getPluginCondition(PluginCategoryEnum.tool),
exclude: ['plugin-c'],
type: 'plugin',
})

View File

@ -7,7 +7,7 @@ import type {
CreatorSearchParams,
CreatorSearchResponse,
GetCollectionTemplatesRequest,
MarketplaceCollection,
PluginCollection,
PluginsSearchParams,
SyncCreatorProfileRequest,
TemplateCollection,
@ -21,7 +21,7 @@ import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/pl
import { type } from '@orpc/contract'
import { base } from './base'
export const collectionsContract = base
export const pluginCollectionsContract = base
.route({
path: '/collections',
method: 'GET',
@ -34,7 +34,7 @@ export const collectionsContract = base
.output(
type<{
data?: {
collections?: MarketplaceCollection[]
collections?: PluginCollection[]
}
}>(),
)

View File

@ -24,7 +24,6 @@ import {
batchAddTemplatesToCollectionContract,
clearCollectionTemplatesContract,
collectionPluginsContract,
collectionsContract,
createTemplateCollectionContract,
deleteTemplateCollectionContract,
getCollectionTemplatesContract,
@ -36,6 +35,7 @@ import {
getTemplateCollectionContract,
getTemplateDslFileContract,
getTemplatesListContract,
pluginCollectionsContract,
searchAdvancedContract,
searchCreatorsAdvancedContract,
searchTemplatesAdvancedContract,
@ -48,7 +48,7 @@ import {
export const marketplaceRouterContract = {
plugins: {
collections: collectionsContract,
collections: pluginCollectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
getPublisherPlugins: getPublisherPluginsContract,