diff --git a/web/app/components/plugins/marketplace/constants.spec.ts b/web/app/components/plugins/marketplace/constants.spec.ts new file mode 100644 index 0000000000..3f14f48cab --- /dev/null +++ b/web/app/components/plugins/marketplace/constants.spec.ts @@ -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') + }) +}) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 46b6c648a2..20ff724ac8 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -23,6 +23,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = { type ValueOf = T[keyof T] export type ActivePluginType = ValueOf +const VALID_PLUGIN_CATEGORIES = new Set(Object.values(PLUGIN_TYPE_SEARCH_MAP)) export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( [ @@ -31,7 +32,6 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( ], ) - 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 { diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 150f9a1fcd..efa45ff3a6 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -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 => ({ +const createMockCollection = (overrides?: Partial): 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') }) }) }) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 3fd34cf026..1661be14ff 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -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() + + 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() + + 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', () => { diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index e711808769..538a6fadc1 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -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') diff --git a/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx index 3a53338cbc..ee8f5c4c04 100644 --- a/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx @@ -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 = () => ( +
+) const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
@@ -25,24 +32,34 @@ const DropdownItem = ({ href, icon, children }: { icon: React.ReactNode children: React.ReactNode }) => ( - + {icon} -
{children}
+
{children}
) -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 }) => ( -
+
{children}
) const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => ( -
+
{items.filter(Boolean).map((item, i) => ( {i > 0 && ยท} @@ -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( + , + ) + } + + if (plugins.length > 0) { + sections.push( + , + ) + } + + if (creators.length > 0) { + sections.push( + , + ) + } + return (
@@ -84,104 +138,12 @@ const SearchDropdown = ({
)} - {plugins.length > 0 && ( - - {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 = ( -
- {TypeIcon && } - {categoryLabel} -
- ) - return ( - - {title} - - )} - > -
{title}
- {!!description && ( -
{description}
- )} - -
- ) - })} -
- )} - - {templates.length > 0 && ( - - {templates.map(template => ( - - {template.icon || '๐Ÿ“„'} - - )} - > -
{template.name}
- 0 - ? [{template.tags.join(', ')}] - : []), - ]} - /> -
- ))} -
- )} - - {creators.length > 0 && ( - - {creators.map(creator => ( - - {creator.display_name} - - )} - > -
- {creator.display_name} - - @ - {creator.unique_handle} - -
- {!!creator.description && ( -
{creator.description}
- )} -
- ))} -
- )} + {sections.map((section, i) => ( + + {i > 0 && } + {section} + + ))}
+ )} + > +
{template.name}
+ {!!descriptionText && ( +
{descriptionText}
+ )} + 0 + ? [{template.tags.join(', ')}] + : []), + ]} + /> + + ) + })} + + ) +} + +/* ---------- Plugins Section ---------- */ + +function PluginsSection({ plugins, getValueFromI18nObject, categoriesMap, t }: { + plugins: Plugin[] + getValueFromI18nObject: ReturnType + categoriesMap: Record + t: ReturnType['t'] +}) { + return ( + + {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 = ( +
+ {TypeIcon && } + {categoryLabel} +
+ ) + return ( + + + {title} + +
+ )} + > +
{title}
+ {!!description && ( +
{description}
+ )} + + + ) + })} + + ) +} + +/* ---------- Creators Section ---------- */ + +function CreatorsSection({ creators, t }: { + creators: Creator[] + t: ReturnType['t'] +}) { + return ( + + {creators.map(creator => ( + +
+ {creator.display_name} +
+
+
{creator.display_name}
+
+ @ + {creator.unique_handle} +
+
+
+ ))} +
+ ) +} + export default SearchDropdown diff --git a/web/app/components/plugins/marketplace/types.ts b/web/app/components/plugins/marketplace/types.ts index 3426066333..73faa6389a 100644 --- a/web/app/components/plugins/marketplace/types.ts +++ b/web/app/components/plugins/marketplace/types.ts @@ -76,6 +76,7 @@ export type Template = { name: string description: Record 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 diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 06e9686ca4..c28bcc6809 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -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, diff --git a/web/app/components/tools/marketplace/index.spec.tsx b/web/app/components/tools/marketplace/index.spec.tsx index 38fafe443f..9b8f05e53a 100644 --- a/web/app/components/tools/marketplace/index.spec.tsx +++ b/web/app/components/tools/marketplace/index.spec.tsx @@ -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', }) diff --git a/web/contract/marketplace.ts b/web/contract/marketplace.ts index c1df148964..eb310ddc46 100644 --- a/web/contract/marketplace.ts +++ b/web/contract/marketplace.ts @@ -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[] } }>(), ) diff --git a/web/contract/router.ts b/web/contract/router.ts index e4494ebf73..9f4a8369a0 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -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,