From b241122cf78786a6a672262b93cdb43bbb1ff6ed Mon Sep 17 00:00:00 2001 From: yessenia Date: Tue, 10 Feb 2026 16:38:29 +0800 Subject: [PATCH] refactor: update marketplace components to use unified terminology and improve search functionality --- .../icons/src/vender/workflow/HumanInLoop.tsx | 2 +- .../install-from-marketplace.tsx | 4 +- .../install-from-marketplace.tsx | 4 +- .../components/plugins/marketplace/atoms.ts | 40 ++- .../plugins/marketplace/category-switch.tsx | 66 +++++ .../plugins/marketplace/constants.ts | 28 +- .../plugins/marketplace/description/index.tsx | 13 +- .../components/plugins/marketplace/hooks.ts | 14 +- .../plugins/marketplace/hydration-server.tsx | 24 +- .../plugins/marketplace/index.spec.tsx | 20 +- .../components/plugins/marketplace/index.tsx | 4 +- .../plugins/marketplace/list/card-wrapper.tsx | 2 +- .../marketplace/list/collection-list.tsx | 213 ++++++++++++++ .../plugins/marketplace/list/flat-list.tsx | 55 ++++ .../plugins/marketplace/list/index.spec.tsx | 219 +++++++------- .../plugins/marketplace/list/index.tsx | 23 +- .../marketplace/list/list-with-collection.tsx | 170 ++++------- .../list/list-wrapper-flat.spec.tsx | 84 ++++++ .../plugins/marketplace/list/list-wrapper.tsx | 189 ++++--------- .../marketplace/list/template-list.tsx | 129 --------- .../marketplace/list/template-search-list.tsx | 27 -- .../marketplace/marketplace-content.tsx | 19 ++ .../marketplace/marketplace-header.tsx | 13 +- .../marketplace/plugin-category-switch.tsx | 99 +++++++ .../marketplace/plugin-type-switch.tsx | 128 --------- .../components/plugins/marketplace/query.ts | 50 +++- .../marketplace/search-box/index.spec.tsx | 45 ++- .../search-box/search-box-wrapper.tsx | 51 ++-- .../search-box/search-dropdown/index.tsx | 205 ++++++++++---- .../marketplace/search-page/creator-card.tsx | 60 ++++ .../plugins/marketplace/search-page/index.tsx | 266 ++++++++++++++++++ .../plugins/marketplace/search-params.ts | 7 +- .../marketplace/search-results-header.tsx | 20 +- .../components/plugins/marketplace/state.ts | 140 ++++----- .../marketplace/template-category-switch.tsx | 78 +++++ .../components/plugins/marketplace/types.ts | 53 +++- .../components/plugins/marketplace/utils.ts | 239 ++++++++++++++-- web/app/components/tools/marketplace/hooks.ts | 34 +-- .../tools/marketplace/index.spec.tsx | 18 +- .../components/tools/marketplace/index.tsx | 14 +- web/app/components/tools/provider-list.tsx | 2 +- web/contract/marketplace.ts | 38 +++ web/contract/router.ts | 12 +- web/i18n/en-US/plugin.json | 8 + web/i18n/zh-Hans/plugin.json | 8 + 45 files changed, 1971 insertions(+), 966 deletions(-) create mode 100644 web/app/components/plugins/marketplace/category-switch.tsx create mode 100644 web/app/components/plugins/marketplace/list/collection-list.tsx create mode 100644 web/app/components/plugins/marketplace/list/flat-list.tsx create mode 100644 web/app/components/plugins/marketplace/list/list-wrapper-flat.spec.tsx delete mode 100644 web/app/components/plugins/marketplace/list/template-list.tsx delete mode 100644 web/app/components/plugins/marketplace/list/template-search-list.tsx create mode 100644 web/app/components/plugins/marketplace/marketplace-content.tsx create mode 100644 web/app/components/plugins/marketplace/plugin-category-switch.tsx delete mode 100644 web/app/components/plugins/marketplace/plugin-type-switch.tsx create mode 100644 web/app/components/plugins/marketplace/search-page/creator-card.tsx create mode 100644 web/app/components/plugins/marketplace/search-page/index.tsx create mode 100644 web/app/components/plugins/marketplace/template-category-switch.tsx diff --git a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx index a94daf432a..8c88642476 100644 --- a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx +++ b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject> + ref?: React.RefObject> }, ) => diff --git a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx index f02e276f55..143f135971 100644 --- a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx @@ -64,8 +64,8 @@ const InstallFromMarketplace = ({ { !isAllPluginsLoading && !collapse && ( (DEFAULT_SORT) @@ -16,16 +17,26 @@ export function useSetMarketplaceSort() { return useSetAtom(marketplaceSortAtom) } -export function useSearchPluginText() { +export function useSearchText() { return useQueryState('q', marketplaceSearchParamsParsers.q) } -export function useActivePluginType() { - return useQueryState('category', marketplaceSearchParamsParsers.category) +export function useActivePluginCategory() { + const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category) + return [getValidatedPluginCategory(category), setCategory] as const +} + +export function useActiveTemplateCategory() { + const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category) + return [getValidatedTemplateCategory(category), setCategory] as const } export function useFilterPluginTags() { return useQueryState('tags', marketplaceSearchParamsParsers.tags) } +export function useSearchTab() { + return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab) +} + /** * Not all categories have collections, so we need to * force the search mode for those categories. @@ -33,23 +44,24 @@ export function useFilterPluginTags() { export const searchModeAtom = atom(null) export function useMarketplaceSearchMode() { - const [searchPluginText] = useSearchPluginText() - const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + // const [searchText] = useSearchText() + const [searchTab] = useSearchTab() + // const [filterPluginTags] = useFilterPluginTags() + const [activePluginCategory] = useActivePluginCategory() const searchMode = useAtomValue(searchModeAtom) - const isSearchMode = !!searchPluginText - || filterPluginTags.length > 0 - || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) + const isSearchMode = searchTab + || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory))) return isSearchMode } export function useMarketplaceMoreClick() { - const [,setQ] = useSearchPluginText() + const [, setQ] = useSearchText() + const [, setSearchTab] = useSearchTab() const setSort = useSetAtom(marketplaceSortAtom) const setSearchMode = useSetAtom(searchModeAtom) - return useCallback((searchParams?: SearchParamsFromCollection) => { + return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => { if (!searchParams) return setQ(searchParams?.query || '') @@ -58,5 +70,7 @@ export function useMarketplaceMoreClick() { sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, }) setSearchMode(true) - }, [setQ, setSort, setSearchMode]) + if (searchTab) + setSearchTab(searchTab) + }, [setQ, setSearchTab, setSort, setSearchMode]) } diff --git a/web/app/components/plugins/marketplace/category-switch.tsx b/web/app/components/plugins/marketplace/category-switch.tsx new file mode 100644 index 0000000000..b3fece96b4 --- /dev/null +++ b/web/app/components/plugins/marketplace/category-switch.tsx @@ -0,0 +1,66 @@ +'use client' + +import { cn } from '@/utils/classnames' + +export type CategoryOption = { + value: string + text: string + icon: React.ReactNode | null +} + +type CategorySwitchProps = { + className?: string + variant?: 'default' | 'hero' + options: CategoryOption[] + activeValue: string + onChange: (value: string) => void +} + +const CategorySwitch = ({ + className, + variant = 'default', + options, + activeValue, + onChange, +}: CategorySwitchProps) => { + const isHeroVariant = variant === 'hero' + + const getItemClassName = (isActive: boolean) => { + if (isHeroVariant) { + return cn( + 'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all', + isActive + ? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted' + : 'hover:bg-state-base-hover', + ) + } + return cn( + 'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs', + ) + } + + return ( +
+ { + options.map(option => ( +
onChange(option.value)} + > + {option.icon} + {option.text} +
+ )) + } +
+ ) +} + +export default CategorySwitch diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 6613fbe3de..46b6c648a2 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -7,8 +7,10 @@ export const DEFAULT_SORT = { export const SCROLL_BOTTOM_THRESHOLD = 100 +export const CATEGORY_ALL = 'all' + export const PLUGIN_TYPE_SEARCH_MAP = { - all: 'all', + all: CATEGORY_ALL, model: PluginCategoryEnum.model, tool: PluginCategoryEnum.tool, agent: PluginCategoryEnum.agent, @@ -28,3 +30,27 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( PLUGIN_TYPE_SEARCH_MAP.tool, ], ) + + +export const TEMPLATE_CATEGORY_MAP = { + all: CATEGORY_ALL, + marketing: 'marketing', + sales: 'sales', + support: 'support', + operations: 'operations', + it: 'it', + knowledge: 'knowledge', + design: 'design', +} as const + +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] +} + +export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory { + const key = (category in TEMPLATE_CATEGORY_MAP ? category : CATEGORY_ALL) as keyof typeof TEMPLATE_CATEGORY_MAP + return TEMPLATE_CATEGORY_MAP[key] +} diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 797f76ce35..524153a4ad 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -7,7 +7,8 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import marketPlaceBg from '@/public/marketplace/hero-bg.jpg' import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg' import { cn } from '@/utils/classnames' -import PluginTypeSwitch from '../plugin-type-switch' +import PluginCategorySwitch from '../plugin-category-switch' +import TemplateCategorySwitch from '../template-category-switch' import { useMarketplaceData } from '../state' type DescriptionProps = { @@ -167,9 +168,15 @@ export const Description = ({ - {/* Plugin type switch tabs - always visible */} + {/* Category switch tabs - Plugin or Template based on creationType */} - + {isTemplatesView + ? ( + + ) + : ( + + )} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 60ba0e0bee..98664a45b7 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -3,7 +3,7 @@ import type { } from '../types' import type { CollectionsAndPluginsSearchParams, - MarketplaceCollection, + PluginCollection, PluginsSearchParams, } from './types' import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' @@ -31,8 +31,8 @@ import { */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() - const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() - const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState>() + const [pluginCollectionsOverride, setPluginCollections] = useState() + const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState>() const { data, @@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => { const isLoading = !!queryParams && (isFetching || isPending) return { - marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections, - setMarketplaceCollections, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, - setMarketplaceCollectionPluginsMap, + pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections, + setPluginCollections, + pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, + setPluginCollectionPluginsMap, queryMarketplaceCollectionsAndPlugins, isLoading, isSuccess, diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index b01f4dd463..379ac7dd44 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' import { getQueryClientServer } from '@/context/query-client-server' import { marketplaceQuery } from '@/service/client' -import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { getValidatedPluginCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import { marketplaceSearchParamsParsers } from './search-params' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceTemplateCollectionsAndTemplates } from './utils' // The server side logic should move to marketplace's codebase so that we can get rid of Next.js @@ -15,16 +15,26 @@ async function getDehydratedState(searchParams?: Promise) { } const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const params = await loadSearchParams(searchParams) + const queryClient = getQueryClientServer() - if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + if (params.creationType === 'templates') { + await queryClient.prefetchQuery({ + queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }), + queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(), + }) + return dehydrate(queryClient) + } + + const pluginCategory = getValidatedPluginCategory(params.category) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)) { return } - const queryClient = getQueryClientServer() - + const collectionsParams = getCollectionsParams(pluginCategory) await queryClient.prefetchQuery({ - queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }), + queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams), }) return dehydrate(queryClient) } diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 9cbe2c0c7d..150f9a1fcd 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -610,8 +610,8 @@ describe('useMarketplaceCollectionsAndPlugins', () => { expect(result.current.isLoading).toBe(false) expect(result.current.isSuccess).toBe(false) expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - expect(result.current.setMarketplaceCollections).toBeDefined() - expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() + expect(result.current.setPluginCollections).toBeDefined() + expect(result.current.setPluginCollectionPluginsMap).toBeDefined() }) it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { @@ -621,34 +621,34 @@ describe('useMarketplaceCollectionsAndPlugins', () => { expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') }) - it('should provide setMarketplaceCollections function', async () => { + it('should provide setPluginCollections function', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - expect(typeof result.current.setMarketplaceCollections).toBe('function') + expect(typeof result.current.setPluginCollections).toBe('function') }) - it('should provide setMarketplaceCollectionPluginsMap function', async () => { + it('should provide setPluginCollectionPluginsMap function', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') + expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function') }) - it('should return marketplaceCollections from data or override', async () => { + it('should return pluginCollections from data or override', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) // Initial state - expect(result.current.marketplaceCollections).toBeUndefined() + expect(result.current.pluginCollections).toBeUndefined() }) - it('should return marketplaceCollectionPluginsMap from data or override', async () => { + it('should return pluginCollectionPluginsMap from data or override', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) // Initial state - expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() + expect(result.current.pluginCollectionPluginsMap).toBeUndefined() }) }) diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index f7dd6cae28..607f8d1ec1 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -2,7 +2,7 @@ import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' import { cn } from '@/utils/classnames' import { HydrateQueryClient } from './hydration-server' -import ListWrapper from './list/list-wrapper' +import MarketplaceContent from './marketplace-content' import MarketplaceHeader from './marketplace-header' type MarketplaceProps = { @@ -28,7 +28,7 @@ const Marketplace = async ({ - diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 35895641b1..0ba093f4e8 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -43,7 +43,7 @@ const CardWrapperComponent = ({ if (showInstallButton) { return (
+ description: Record + searchable?: boolean + search_params?: { query?: string, sort_by?: string, sort_order?: string } +} + +type ViewMoreButtonProps = { + searchParams?: SearchParamsFromCollection + searchTab?: SearchTab +} + +function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) { + const { t } = useTranslation() + const onMoreClick = useMarketplaceMoreClick() + + return ( +
onMoreClick(searchParams, searchTab)} + > + {t('marketplace.viewMore', { ns: 'plugin' })} + +
+ ) +} + +export { ViewMoreButton } + +type CollectionHeaderProps = { + collection: TCollection + itemsLength: number + locale: Locale + carouselCollectionNames: string[] + viewMore: React.ReactNode +} + +function CollectionHeader({ + collection, + itemsLength, + locale, + carouselCollectionNames, + viewMore, +}: CollectionHeaderProps) { + const showViewMore = collection.searchable + && (carouselCollectionNames.includes(collection.name) || itemsLength > GRID_DISPLAY_LIMIT) + + return ( +
+
+
+ {collection.label[getLanguage(locale)]} +
+
+ {collection.description[getLanguage(locale)]} +
+
+ {showViewMore && viewMore} +
+ ) +} + +export { CarouselCollection, CollectionHeader } + +type CarouselCollectionProps = { + items: TItem[] + getItemKey: (item: TItem) => string + renderCard: (item: TItem) => React.ReactNode + cardContainerClassName?: string +} + +function CarouselCollection({ + items, + getItemKey, + renderCard, + cardContainerClassName, +}: CarouselCollectionProps) { + const rows: TItem[][] = [] + for (let i = 0; i < items.length; i += 2) + rows.push(items.slice(i, i + 2)) + + return ( + 8} + showPagination={items.length > 8} + autoPlay={items.length > 8} + autoPlayInterval={5000} + > + {rows.map((columnItems, idx) => ( +
+ {columnItems.map(item => ( +
{renderCard(item)}
+ ))} +
+ ))} +
+ ) +} + +type CollectionListProps = { + collections: TCollection[] + collectionItemsMap: Record + /** Field name to use as item key (e.g. 'plugin_id', 'template_id'). */ + itemKeyField: keyof TItem + renderCard: (item: TItem) => React.ReactNode + /** Collection names that use carousel layout (e.g. ['partners'], ['featured']). */ + carouselCollectionNames: string[] + /** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */ + viewMoreSearchTab?: SearchTab + gridClassName?: string + cardContainerClassName?: string + emptyClassName?: string +} + +function CollectionList({ + collections, + collectionItemsMap, + itemKeyField, + renderCard, + carouselCollectionNames, + viewMoreSearchTab, + gridClassName = GRID_CLASS, + cardContainerClassName, + emptyClassName, +}: CollectionListProps) { + const locale = useLocale() + + const collectionsWithItems = collections.filter((collection) => { + return collectionItemsMap[collection.name]?.length + }) + + if (collectionsWithItems.length === 0) { + return + } + + return ( + <> + { + collectionsWithItems.map((collection) => { + const items = collectionItemsMap[collection.name] + const isCarouselCollection = carouselCollectionNames.includes(collection.name) + + return ( +
+ } + /> + {isCarouselCollection + ? ( + getItemKeyByField(item, itemKeyField)} + renderCard={renderCard} + cardContainerClassName={cardContainerClassName} + /> + ) + : ( +
+ {items.slice(0, GRID_DISPLAY_LIMIT).map(item => ( +
+ {renderCard(item)} +
+ ))} +
+ )} +
+ ) + }) + } + + ) +} + +export default CollectionList diff --git a/web/app/components/plugins/marketplace/list/flat-list.tsx b/web/app/components/plugins/marketplace/list/flat-list.tsx new file mode 100644 index 0000000000..8f47041eb7 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/flat-list.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { Template } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import Empty from '../empty' +import CardWrapper from './card-wrapper' +import { GRID_CLASS } from './collection-list' +import TemplateCard from './template-card' + +type PluginsVariant = { + variant: 'plugins' + items: Plugin[] + showInstallButton?: boolean +} + +type TemplatesVariant = { + variant: 'templates' + items: Template[] +} + +type FlatListProps = PluginsVariant | TemplatesVariant + +const FlatList = (props: FlatListProps) => { + if (!props.items.length) + return + + if (props.variant === 'plugins') { + const { items, showInstallButton } = props + return ( +
+ {items.map(plugin => ( + + ))} +
+ ) + } + + const { items } = props + return ( +
+ {items.map(template => ( + + ))} +
+ ) +} + +export default FlatList diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index 01fffc0c66..17c378d0ba 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,4 +1,4 @@ -import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' +import type { PluginCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -36,8 +36,8 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { mockMarketplaceData: { plugins: undefined as Plugin[] | undefined, pluginsTotal: 0, - marketplaceCollections: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMap: undefined as Record | undefined, + pluginCollections: undefined as PluginCollection[] | undefined, + pluginCollectionPluginsMap: undefined as Record | undefined, isLoading: false, page: 1, }, @@ -207,7 +207,7 @@ const createMockPluginList = (count: number): Plugin[] => label: { 'en-US': `Plugin ${i}` }, })) -const createMockCollection = (overrides?: Partial): MarketplaceCollection => ({ +const createMockCollection = (overrides?: Partial): PluginCollection => ({ name: `collection-${Math.random().toString(36).substring(7)}`, label: { 'en-US': 'Test Collection' }, description: { 'en-US': 'Test collection description' }, @@ -219,7 +219,7 @@ const createMockCollection = (overrides?: Partial): Marke ...overrides, }) -const createMockCollectionList = (count: number): MarketplaceCollection[] => +const createMockCollectionList = (count: number): PluginCollection[] => Array.from({ length: count }, (_, i) => createMockCollection({ name: `collection-${i}`, @@ -232,8 +232,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] => // ================================ describe('List', () => { const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, + pluginCollections: [] as PluginCollection[], + pluginCollectionPluginsMap: {} as Record, plugins: undefined, showInstallButton: false, cardContainerClassName: '', @@ -267,8 +267,8 @@ describe('List', () => { render( , ) @@ -313,8 +313,8 @@ describe('List', () => { render( , ) @@ -425,12 +425,12 @@ describe('List', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty marketplaceCollections', () => { + it('should handle empty pluginCollections', () => { render( , ) @@ -447,8 +447,8 @@ describe('List', () => { render( , ) @@ -495,12 +495,12 @@ describe('List', () => { // ================================ describe('ListWithCollection', () => { const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, + variant: 'plugins' as const, + collections: [] as PluginCollection[], + collectionItemsMap: {} as Record, showInstallButton: false, cardContainerClassName: '', cardRender: undefined, - onMoreClick: undefined, } beforeEach(() => { @@ -527,8 +527,8 @@ describe('ListWithCollection', () => { render( , ) @@ -547,8 +547,8 @@ describe('ListWithCollection', () => { render( , ) @@ -567,8 +567,8 @@ describe('ListWithCollection', () => { render( , ) @@ -583,19 +583,19 @@ describe('ListWithCollection', () => { describe('View More Button', () => { it('should render View More button when collection is searchable', () => { const collections = [createMockCollection({ - name: 'collection-0', + name: 'partners', searchable: true, search_params: { query: 'test' }, })] const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), + partners: createMockPluginList(1), } render( , ) @@ -614,8 +614,8 @@ describe('ListWithCollection', () => { render( , ) @@ -625,19 +625,19 @@ describe('ListWithCollection', () => { it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ - name: 'collection-0', + name: 'partners', searchable: true, search_params: searchParams, })] const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), + partners: createMockPluginList(1), } render( , ) @@ -668,8 +668,8 @@ describe('ListWithCollection', () => { render( , ) @@ -692,8 +692,8 @@ describe('ListWithCollection', () => { const { container } = render( , ) @@ -710,8 +710,8 @@ describe('ListWithCollection', () => { const { container } = render( , ) @@ -729,8 +729,8 @@ describe('ListWithCollection', () => { render( , ) @@ -745,8 +745,8 @@ describe('ListWithCollection', () => { render( , ) @@ -763,8 +763,8 @@ describe('ListWithCollection', () => { render( , ) @@ -783,8 +783,8 @@ describe('ListWrapper', () => { // Reset mock data mockMarketplaceData.plugins = undefined mockMarketplaceData.pluginsTotal = 0 - mockMarketplaceData.marketplaceCollections = undefined - mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.pluginCollections = undefined + mockMarketplaceData.pluginCollectionPluginsMap = undefined mockMarketplaceData.isLoading = false mockMarketplaceData.page = 1 }) @@ -861,8 +861,8 @@ describe('ListWrapper', () => { describe('List Rendering Logic', () => { it('should render collections when not loading', () => { mockMarketplaceData.isLoading = false - mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) - mockMarketplaceData.marketplaceCollectionPluginsMap = { + mockMarketplaceData.pluginCollections = createMockCollectionList(1) + mockMarketplaceData.pluginCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } @@ -874,8 +874,8 @@ describe('ListWrapper', () => { it('should render List when loading but page > 1', () => { mockMarketplaceData.isLoading = true mockMarketplaceData.page = 2 - mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) - mockMarketplaceData.marketplaceCollectionPluginsMap = { + mockMarketplaceData.pluginCollections = createMockCollectionList(1) + mockMarketplaceData.pluginCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } @@ -899,13 +899,13 @@ describe('ListWrapper', () => { }) it('should show View More button and call moreClick hook', () => { - mockMarketplaceData.marketplaceCollections = [createMockCollection({ - name: 'collection-0', + mockMarketplaceData.pluginCollections = [createMockCollection({ + name: 'partners', searchable: true, search_params: { query: 'test' }, })] - mockMarketplaceData.marketplaceCollectionPluginsMap = { - 'collection-0': createMockPluginList(1), + mockMarketplaceData.pluginCollectionPluginsMap = { + partners: createMockPluginList(1), } render() @@ -973,8 +973,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -991,8 +991,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1010,8 +1010,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1030,8 +1030,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1050,8 +1050,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1071,8 +1071,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1089,8 +1089,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1105,8 +1105,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1121,8 +1121,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1147,8 +1147,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1167,8 +1167,8 @@ describe('CardWrapper (via List integration)', () => { render( , @@ -1182,8 +1182,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1205,8 +1205,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1222,8 +1222,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1239,8 +1239,8 @@ describe('CardWrapper (via List integration)', () => { render( , ) @@ -1261,8 +1261,8 @@ describe('Combined Workflows', () => { mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.isLoading = false mockMarketplaceData.page = 1 - mockMarketplaceData.marketplaceCollections = undefined - mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.pluginCollections = undefined + mockMarketplaceData.pluginCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { @@ -1275,8 +1275,8 @@ describe('Combined Workflows', () => { // Simulate loading complete mockMarketplaceData.isLoading = false - mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) - mockMarketplaceData.marketplaceCollectionPluginsMap = { + mockMarketplaceData.pluginCollections = createMockCollectionList(1) + mockMarketplaceData.pluginCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } @@ -1287,8 +1287,8 @@ describe('Combined Workflows', () => { }) it('should transition from collections to search results', async () => { - mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) - mockMarketplaceData.marketplaceCollectionPluginsMap = { + mockMarketplaceData.pluginCollections = createMockCollectionList(1) + mockMarketplaceData.pluginCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } @@ -1350,8 +1350,9 @@ describe('Accessibility', () => { const { container } = render( , ) @@ -1360,19 +1361,20 @@ describe('Accessibility', () => { expect(headings.length).toBeGreaterThan(0) }) - it('should have clickable View More button', () => { + it('should have clickable View More button', () => { const collections = [createMockCollection({ - name: 'collection-0', + name: 'partners', searchable: true, })] const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), + partners: createMockPluginList(1), } render( , ) @@ -1381,18 +1383,18 @@ describe('Accessibility', () => { expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer') }) - it('should have proper grid layout for cards', () => { + it('should have proper grid layout for cards', () => { const plugins = createMockPluginList(4) const { container } = render( , ) - const grid = container.querySelector('.grid-cols-4') + const grid = container.querySelector('.grid') expect(grid).toBeInTheDocument() }) }) @@ -1411,8 +1413,8 @@ describe('Performance', () => { const startTime = performance.now() render( , ) @@ -1432,8 +1434,9 @@ describe('Performance', () => { const startTime = performance.now() render( , ) const endTime = performance.now() diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 38db88815b..8a579ca886 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -1,14 +1,16 @@ 'use client' + import type { Plugin } from '../../types' -import type { MarketplaceCollection } from '../types' +import type { PluginCollection } from '../types' import { cn } from '@/utils/classnames' import Empty from '../empty' import CardWrapper from './card-wrapper' +import { GRID_CLASS } from './collection-list' import ListWithCollection from './list-with-collection' type ListProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record + pluginCollections: PluginCollection[] + pluginCollectionPluginsMap: Record plugins?: Plugin[] showInstallButton?: boolean cardContainerClassName?: string @@ -16,8 +18,8 @@ type ListProps = { emptyClassName?: string } const List = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, + pluginCollections, + pluginCollectionPluginsMap, plugins, showInstallButton, cardContainerClassName, @@ -29,8 +31,9 @@ const List = ({ { !plugins && ( +
{ plugins.map((plugin) => { if (cardRender) diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 0a31fb9680..89b53e1f22 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -1,134 +1,82 @@ 'use client' -import type { MarketplaceCollection } from '../types' +import type { PluginCollection, Template, TemplateCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { useLocale, useTranslation } from '#i18n' -import { RiArrowRightSLine } from '@remixicon/react' -import { getLanguage } from '@/i18n-config/language' -import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' -import Carousel from './carousel' +import CollectionList, { CAROUSEL_COLLECTION_NAMES } from './collection-list' +import TemplateCard from './template-card' -type ListWithCollectionProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record - showInstallButton?: boolean +type BaseProps = { cardContainerClassName?: string +} + +type PluginsVariant = BaseProps & { + variant: 'plugins' + collections: PluginCollection[] + collectionItemsMap: Record + showInstallButton?: boolean cardRender?: (plugin: Plugin) => React.JSX.Element | null } -const PARTNERS_COLLECTION_NAME = 'partners' -const GRID_DISPLAY_LIMIT = 8 // show up to 8 items +type TemplatesVariant = BaseProps & { + variant: 'templates' + collections: TemplateCollection[] + collectionItemsMap: Record +} -const ListWithCollection = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, - showInstallButton, - cardContainerClassName, - cardRender, -}: ListWithCollectionProps) => { - const { t } = useTranslation() - const locale = useLocale() - const onMoreClick = useMarketplaceMoreClick() +type ListWithCollectionProps = PluginsVariant | TemplatesVariant - const renderPluginCard = (plugin: Plugin) => { - if (cardRender) - return cardRender(plugin) +const ListWithCollection = (props: ListWithCollectionProps) => { + const { variant, cardContainerClassName } = props + + if (variant === 'plugins') { + const { + collections, + collectionItemsMap, + showInstallButton, + cardRender, + } = props + + const renderPluginCard = (plugin: Plugin) => { + if (cardRender) + return cardRender(plugin) + + return ( + + ) + } return ( - ) } - const renderPartnersCarousel = (collection: MarketplaceCollection, plugins: Plugin[]) => { - // Partners collection: 2-row carousel with auto-play - const rows: Plugin[][] = [] - for (let i = 0; i < plugins.length; i += 2) { - // Group plugins in pairs (2 per column) - rows.push(plugins.slice(i, i + 2)) - } + const { collections, collectionItemsMap } = props - return ( - 8} - showPagination={plugins.length > 8} - autoPlay={plugins.length > 8} - autoPlayInterval={5000} - > - {rows.map(columnPlugins => ( -
- {columnPlugins.map(plugin => ( -
- {renderPluginCard(plugin)} -
- ))} -
- ))} -
- ) - } - - const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => { - // Other collections: responsive grid - const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT) - - return ( -
- {displayPlugins.map(plugin => ( -
- {renderPluginCard(plugin)} -
- ))} -
- ) - } + const renderTemplateCard = (template: Template) => ( + + ) return ( - <> - { - marketplaceCollections.filter((collection) => { - return marketplaceCollectionPluginsMap[collection.name]?.length - }).map((collection) => { - const plugins = marketplaceCollectionPluginsMap[collection.name] - const isPartnersCollection = collection.name === PARTNERS_COLLECTION_NAME - const showViewMore = collection.searchable && (isPartnersCollection || plugins.length > GRID_DISPLAY_LIMIT) - - return ( -
-
-
-
{collection.label[getLanguage(locale)]}
-
{collection.description[getLanguage(locale)]}
-
- {showViewMore && ( -
onMoreClick(collection.search_params)} - > - {t('marketplace.viewMore', { ns: 'plugin' })} - -
- )} -
- {isPartnersCollection - ? renderPartnersCarousel(collection, plugins) - : renderGridCollection(collection, plugins)} -
- ) - }) - } - + ) } diff --git a/web/app/components/plugins/marketplace/list/list-wrapper-flat.spec.tsx b/web/app/components/plugins/marketplace/list/list-wrapper-flat.spec.tsx new file mode 100644 index 0000000000..e5ea396dae --- /dev/null +++ b/web/app/components/plugins/marketplace/list/list-wrapper-flat.spec.tsx @@ -0,0 +1,84 @@ +import type { Template } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ListWrapper from './list-wrapper' + +const { mockMarketplaceData } = vi.hoisted(() => ({ + mockMarketplaceData: { + creationType: 'plugins' as 'plugins' | 'templates', + isLoading: false, + page: 1, + isFetchingNextPage: false, + pluginCollections: [], + pluginCollectionPluginsMap: {}, + plugins: undefined as Plugin[] | undefined, + templateCollections: [], + templateCollectionTemplatesMap: {}, + templates: undefined as Template[] | undefined, + }, +})) + +vi.mock('../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
Loading
, +})) + +vi.mock('./flat-list', () => ({ + default: ({ variant, items }: { variant: 'plugins' | 'templates', items: unknown[] }) => ( +
+ {items.length} +
+ ), +})) + +vi.mock('./list-with-collection', () => ({ + default: ({ variant }: { variant: 'plugins' | 'templates' }) => ( +
collection
+ ), +})) + +describe('ListWrapper flat rendering', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMarketplaceData.creationType = 'plugins' + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 + mockMarketplaceData.isFetchingNextPage = false + mockMarketplaceData.plugins = undefined + mockMarketplaceData.templates = undefined + }) + + it('renders plugin flat list when plugin items exist', () => { + mockMarketplaceData.creationType = 'plugins' + mockMarketplaceData.plugins = [{ org: 'o', name: 'p' } as Plugin] + + render() + + expect(screen.getByTestId('flat-list-plugins')).toBeInTheDocument() + expect(screen.queryByTestId('collection-list-plugins')).not.toBeInTheDocument() + }) + + it('renders template flat list when template items exist', () => { + mockMarketplaceData.creationType = 'templates' + mockMarketplaceData.templates = [{ template_id: 't1' } as Template] + + render() + + expect(screen.getByTestId('flat-list-templates')).toBeInTheDocument() + expect(screen.queryByTestId('collection-list-templates')).not.toBeInTheDocument() + }) + + it('renders template collection list when templates are undefined', () => { + mockMarketplaceData.creationType = 'templates' + mockMarketplaceData.templates = undefined + + render() + + expect(screen.getByTestId('collection-list-templates')).toBeInTheDocument() + expect(screen.queryByTestId('flat-list-templates')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 3646f5d032..29ba4e53a9 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,163 +1,74 @@ 'use client' -import type { ActivePluginType } from '../constants' -import { useTranslation } from '#i18n' -import { useState } from 'react' + import Loading from '@/app/components/base/loading' -import SegmentedControl from '@/app/components/base/segmented-control' -import CategoriesFilter from '../../plugin-page/filter-management/category-filter' -import TagFilter from '../../plugin-page/filter-management/tag-filter' -import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode } from '../atoms' -import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' -import SortDropdown from '../sort-dropdown' import { useMarketplaceData } from '../state' -import List from './index' -import TemplateList from './template-list' -import TemplateSearchList from './template-search-list' +import FlatList from './flat-list' +import ListWithCollection from './list-with-collection' type ListWrapperProps = { showInstallButton?: boolean } -type SearchScope = 'all' | 'plugins' | 'creators' -const searchScopeOptionKeys = [ - { value: 'all', textKey: 'marketplace.searchFilterAll' }, - { value: 'plugins', textKey: 'marketplace.searchFilterPlugins' }, - { value: 'creators', textKey: 'marketplace.searchFilterCreators' }, -] as const satisfies ReadonlyArray<{ value: SearchScope, textKey: 'marketplace.searchFilterAll' | 'marketplace.searchFilterPlugins' | 'marketplace.searchFilterCreators' }> - -const ListWrapper = ({ - showInstallButton, -}: ListWrapperProps) => { - const { t } = useTranslation() - const isSearchMode = useMarketplaceSearchMode() - const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() - const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() - const [searchScope, setSearchScope] = useState('all') +const ListWrapper = ({ showInstallButton }: ListWrapperProps) => { const marketplaceData = useMarketplaceData() - const { - creationType, - isLoading, - } = marketplaceData + const { creationType, isLoading, page, isFetchingNextPage } = marketplaceData + + const isPluginView = creationType === 'plugins' + + const renderContent = () => { + if (!isPluginView) { + const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData + if (templates !== undefined) { + return ( + + ) + } + + return ( + + ) + } + + const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData + if (plugins !== undefined) { + return ( + + ) + } - // Templates view - if (creationType === 'templates') { - const { - templateCollections, - templateCollectionTemplatesMap, - templates, - isSearchMode: isTemplateSearchMode, - } = marketplaceData return ( -
- { - isLoading && ( -
- -
- ) - } - { - !isLoading && ( - isTemplateSearchMode - ? ( - - ) - : ( - - ) - ) - } -
+ ) } - // Plugins view (default) - const { - plugins, - pluginsTotal, - marketplaceCollections, - marketplaceCollectionPluginsMap, - isFetchingNextPage, - page, - } = marketplaceData - - const pluginsCount = pluginsTotal || 0 - const searchScopeOptions: Array<{ value: SearchScope, text: string, count: number }> = searchScopeOptionKeys.map(option => ({ - value: option.value, - text: t(option.textKey, { ns: 'plugin' }), - count: option.value === 'creators' ? 0 : pluginsCount, - })) - return (
- {plugins && !isSearchMode && ( -
-
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
-
- + {isLoading && page === 1 && ( +
+
)} - {isSearchMode && ( -
-
- { - setSearchScope(value as SearchScope) - }} - options={searchScopeOptions} - /> - { - if (categories.length === 0) { - handleActivePluginTypeChange(PLUGIN_TYPE_SEARCH_MAP.all) - return - } - handleActivePluginTypeChange(categories[categories.length - 1] as ActivePluginType) - }} - /> - -
- -
- )} - { - isLoading && page === 1 && ( -
- -
- ) - } - { - (!isLoading || page > 1) && ( - - ) - } - { - isFetchingNextPage && ( - - ) - } + {(!isLoading || page > 1) && renderContent()} + {isFetchingNextPage && }
) } diff --git a/web/app/components/plugins/marketplace/list/template-list.tsx b/web/app/components/plugins/marketplace/list/template-list.tsx deleted file mode 100644 index 00f0ba4062..0000000000 --- a/web/app/components/plugins/marketplace/list/template-list.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client' - -import type { Template, TemplateCollection } from '../types' -import { useLocale, useTranslation } from '#i18n' -import { RiArrowRightSLine } from '@remixicon/react' -import { getLanguage } from '@/i18n-config/language' -import Empty from '../empty' -import Carousel from './carousel' -import TemplateCard from './template-card' - -type TemplateListProps = { - templateCollections: TemplateCollection[] - templateCollectionTemplatesMap: Record - cardContainerClassName?: string -} - -const FEATURED_COLLECTION_NAME = 'featured' -const GRID_DISPLAY_LIMIT = 8 - -const TemplateList = ({ - templateCollections, - templateCollectionTemplatesMap, - cardContainerClassName, -}: TemplateListProps) => { - const { t } = useTranslation() - const locale = useLocale() - - const renderTemplateCard = (template: Template) => { - return ( - - ) - } - - const renderFeaturedCarousel = (collection: TemplateCollection, templates: Template[]) => { - // Featured collection: 2-row carousel with auto-play - const rows: Template[][] = [] - for (let i = 0; i < templates.length; i += 2) { - rows.push(templates.slice(i, i + 2)) - } - - return ( - 8} - showPagination={templates.length > 8} - autoPlay={templates.length > 8} - autoPlayInterval={5000} - > - {rows.map(columnTemplates => ( -
- {columnTemplates.map(template => ( -
- {renderTemplateCard(template)} -
- ))} -
- ))} -
- ) - } - - const renderGridCollection = (collection: TemplateCollection, templates: Template[]) => { - const displayTemplates = templates.slice(0, GRID_DISPLAY_LIMIT) - - return ( -
- {displayTemplates.map(template => ( -
- {renderTemplateCard(template)} -
- ))} -
- ) - } - - const collectionsWithTemplates = templateCollections.filter((collection) => { - return templateCollectionTemplatesMap[collection.name]?.length - }) - - if (collectionsWithTemplates.length === 0) { - return - } - - return ( - <> - { - collectionsWithTemplates.map((collection) => { - const templates = templateCollectionTemplatesMap[collection.name] - const isFeaturedCollection = collection.name === FEATURED_COLLECTION_NAME - const showViewMore = collection.searchable && (isFeaturedCollection || templates.length > GRID_DISPLAY_LIMIT) - - return ( -
-
-
-
{collection.label[getLanguage(locale)]}
-
{collection.description[getLanguage(locale)]}
-
- {showViewMore && ( -
- {t('marketplace.viewMore', { ns: 'plugin' })} - -
- )} -
- {isFeaturedCollection - ? renderFeaturedCarousel(collection, templates) - : renderGridCollection(collection, templates)} -
- ) - }) - } - - ) -} - -export default TemplateList diff --git a/web/app/components/plugins/marketplace/list/template-search-list.tsx b/web/app/components/plugins/marketplace/list/template-search-list.tsx deleted file mode 100644 index 086db7afa1..0000000000 --- a/web/app/components/plugins/marketplace/list/template-search-list.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'use client' - -import type { Template } from '../types' -import Empty from '../empty' -import TemplateCard from './template-card' - -type TemplateSearchListProps = { - templates: Template[] -} - -const TemplateSearchList = ({ templates }: TemplateSearchListProps) => { - if (templates.length === 0) { - return - } - - return ( -
- {templates.map(template => ( -
- -
- ))} -
- ) -} - -export default TemplateSearchList diff --git a/web/app/components/plugins/marketplace/marketplace-content.tsx b/web/app/components/plugins/marketplace/marketplace-content.tsx new file mode 100644 index 0000000000..28b4ed37de --- /dev/null +++ b/web/app/components/plugins/marketplace/marketplace-content.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useSearchTab } from './atoms' +import ListWrapper from './list/list-wrapper' +import SearchPage from './search-page' + +type MarketplaceContentProps = { + showInstallButton?: boolean +} + +const MarketplaceContent = ({ showInstallButton }: MarketplaceContentProps) => { + const [searchTab] = useSearchTab() + + if (searchTab) + return + return +} + +export default MarketplaceContent diff --git a/web/app/components/plugins/marketplace/marketplace-header.tsx b/web/app/components/plugins/marketplace/marketplace-header.tsx index 868cb1ff31..ce61295c46 100644 --- a/web/app/components/plugins/marketplace/marketplace-header.tsx +++ b/web/app/components/plugins/marketplace/marketplace-header.tsx @@ -1,9 +1,8 @@ 'use client' -import { useMarketplaceSearchMode } from './atoms' +import { useSearchTab } from './atoms' import { Description } from './description' import SearchResultsHeader from './search-results-header' -import { useMarketplaceData } from './state' type MarketplaceHeaderProps = { descriptionClassName?: string @@ -11,14 +10,10 @@ type MarketplaceHeaderProps = { } const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => { - const { creationType, isSearchMode: templatesSearchMode } = useMarketplaceData() - const pluginsSearchMode = useMarketplaceSearchMode() + const [searchTab] = useSearchTab() - // Use templates search mode when viewing templates, otherwise use plugins search mode - const isSearchMode = creationType === 'templates' ? templatesSearchMode : pluginsSearchMode - - if (isSearchMode) - return + if (searchTab) + return return } diff --git a/web/app/components/plugins/marketplace/plugin-category-switch.tsx b/web/app/components/plugins/marketplace/plugin-category-switch.tsx new file mode 100644 index 0000000000..c9264fd790 --- /dev/null +++ b/web/app/components/plugins/marketplace/plugin-category-switch.tsx @@ -0,0 +1,99 @@ +'use client' + +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 } from './atoms' +import CategorySwitch from './category-switch' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons' + +type PluginTypeSwitchProps = { + className?: string + variant?: 'default' | 'hero' +} + +const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => { + if (value === PLUGIN_TYPE_SEARCH_MAP.all) + return isHeroVariant ? : null + if (value === PLUGIN_TYPE_SEARCH_MAP.bundle) + return + const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum] + return Icon ? : null +} + +const PluginCategorySwitch = ({ + className, + variant = 'default', +}: PluginTypeSwitchProps) => { + const { t } = useTranslation() + const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory() + const setSearchMode = useSetAtom(searchModeAtom) + + 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), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.model, + text: t('category.models', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.tool, + text: t('category.tools', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.datasource, + text: t('category.datasources', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.trigger, + text: t('category.triggers', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.agent, + text: t('category.agents', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.extension, + text: t('category.extensions', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension), + }, + { + value: PLUGIN_TYPE_SEARCH_MAP.bundle, + text: t('category.bundles', { ns: 'plugin' }), + icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle), + }, + ] + + const handleChange = (value: string) => { + handleActivePluginCategoryChange(value) + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) { + setSearchMode(null) + } + } + + return ( + + ) +} + +export default PluginCategorySwitch diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx deleted file mode 100644 index d96425bf5c..0000000000 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client' -import type { ActivePluginType } from './constants' -import type { PluginCategoryEnum } from '@/app/components/plugins/types' -import { useTranslation } from '#i18n' -import { - RiApps2Line, - RiArchive2Line, -} from '@remixicon/react' -import { useSetAtom } from 'jotai' -import { cn } from '@/utils/classnames' -import { searchModeAtom, useActivePluginType } from './atoms' -import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons' - -type PluginTypeSwitchProps = { - className?: string - variant?: 'default' | 'hero' -} -const PluginTypeSwitch = ({ - className, - variant = 'default', -}: PluginTypeSwitchProps) => { - const { t } = useTranslation() - const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() - const setSearchMode = useSetAtom(searchModeAtom) - - const isHeroVariant = variant === 'hero' - - const getTypeIcon = (value: ActivePluginType) => { - if (value === PLUGIN_TYPE_SEARCH_MAP.all) - return isHeroVariant ? : null - if (value === PLUGIN_TYPE_SEARCH_MAP.bundle) - return - const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum] - return Icon ? : null - } - - const options: Array<{ - value: ActivePluginType - text: string - icon: React.ReactNode | null - }> = [ - { - 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), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.model, - text: t('category.models', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.tool, - text: t('category.tools', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.datasource, - text: t('category.datasources', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.trigger, - text: t('category.triggers', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.agent, - text: t('category.agents', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.extension, - text: t('category.extensions', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension), - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.bundle, - text: t('category.bundles', { ns: 'plugin' }), - icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle), - }, - ] - - const getItemClassName = (isActive: boolean) => { - if (isHeroVariant) { - return cn( - 'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all', - isActive - ? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted' - : 'hover:bg-state-base-hover', - ) - } - return cn( - 'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', - isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs', - ) - } - - return ( -
- { - options.map(option => ( -
{ - handleActivePluginTypeChange(option.value) - if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { - setSearchMode(null) - } - }} - > - {option.icon} - {option.text} -
- )) - } -
- ) -} - -export default PluginTypeSwitch diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index 3dcbf8c226..c5b70552fd 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -1,32 +1,37 @@ -import type { PluginsSearchParams, TemplateSearchParams } from './types' +import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams, UnifiedSearchParams } from './types' import type { MarketPlaceInputs } from '@/contract/router' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { marketplaceQuery } from '@/service/client' -import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates } from './utils' +import { getMarketplaceCollectionsAndPlugins, getMarketplaceCreators, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates, getMarketplaceUnifiedSearch } from './utils' export function useMarketplaceCollectionsAndPlugins( - collectionsParams: MarketPlaceInputs['collections']['query'], + collectionsParams: MarketPlaceInputs['plugins']['collections']['query'], + options?: { enabled?: boolean }, ) { return useQuery({ - queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), + queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + enabled: options?.enabled !== false, }) } export function useMarketplaceTemplateCollectionsAndTemplates( query?: { page?: number, page_size?: number, condition?: string }, + options?: { enabled?: boolean }, ) { return useQuery({ queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }), queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }), + enabled: options?.enabled !== false, }) } export function useMarketplacePlugins( queryParams: PluginsSearchParams | undefined, + options?: { enabled?: boolean }, ) { return useInfiniteQuery({ - queryKey: marketplaceQuery.searchAdvanced.queryKey({ + queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({ input: { body: queryParams!, params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, @@ -39,12 +44,13 @@ export function useMarketplacePlugins( return loaded < (lastPage.total || 0) ? nextPage : undefined }, initialPageParam: 1, - enabled: queryParams !== undefined, + enabled: options?.enabled !== false && queryParams !== undefined, }) } export function useMarketplaceTemplates( queryParams: TemplateSearchParams | undefined, + options?: { enabled?: boolean }, ) { return useInfiniteQuery({ queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({ @@ -59,6 +65,38 @@ export function useMarketplaceTemplates( return loaded < (lastPage.total || 0) ? nextPage : undefined }, initialPageParam: 1, + enabled: options?.enabled !== false && queryParams !== undefined, + }) +} + +export function useMarketplaceCreators( + queryParams: CreatorSearchParams | undefined, +) { + return useInfiniteQuery({ + queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({ + input: { + body: queryParams!, + }, + }), + queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.page_size + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + }) +} + +export function useMarketplaceUnifiedSearch( + queryParams: UnifiedSearchParams | undefined, +) { + return useQuery({ + queryKey: marketplaceQuery.searchUnified.queryKey({ + input: { body: queryParams! }, + }), + queryFn: ({ signal }) => getMarketplaceUnifiedSearch(queryParams, signal), enabled: queryParams !== undefined, }) } 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 8f150957d0..3fd34cf026 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -56,19 +56,19 @@ vi.mock('@/hooks/use-i18n', () => ({ // Mock marketplace state hooks const { - mockSearchPluginText, - mockHandleSearchPluginTextChange, + mockSearchText, + mockHandleSearchTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange, - mockActivePluginType, + mockActivePluginCategory, mockSortValue, } = vi.hoisted(() => { return { - mockSearchPluginText: '', - mockHandleSearchPluginTextChange: vi.fn(), + mockSearchText: '', + mockHandleSearchTextChange: vi.fn(), mockFilterPluginTags: [] as string[], mockHandleFilterPluginTagsChange: vi.fn(), - mockActivePluginType: 'all', + mockActivePluginCategory: 'all', mockSortValue: { sortBy: 'install_count', sortOrder: 'DESC', @@ -77,13 +77,23 @@ const { }) vi.mock('../atoms', () => ({ - useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], + useSearchText: () => [mockSearchText, mockHandleSearchTextChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], - useActivePluginType: () => [mockActivePluginType, vi.fn()], + useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()], useMarketplaceSortValue: () => mockSortValue, searchModeAtom: {}, })) +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils') + return { + ...actual, + mapUnifiedPluginToPlugin: (item: Plugin) => item, + mapUnifiedTemplateToTemplate: (item: unknown) => item, + mapUnifiedCreatorToCreator: (item: unknown) => item, + } +}) + // Mock useTags hook const mockTags: Tag[] = [ { name: 'agent', label: 'Agent' }, @@ -118,8 +128,15 @@ vi.mock('@/app/components/plugins/hooks', () => ({ let mockDropdownPlugins: Plugin[] = [] vi.mock('../query', () => ({ - useMarketplacePlugins: () => ({ - data: { pages: [{ plugins: mockDropdownPlugins }] }, + useMarketplaceUnifiedSearch: () => ({ + data: { + plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length }, + templates: { items: [], total: 0 }, + creators: { items: [], total: 0 }, + organizations: { items: [], total: 0 }, + page: 1, + page_size: 5, + }, isLoading: false, }), })) @@ -548,6 +565,8 @@ describe('SearchDropdown', () => { , ) @@ -566,6 +585,8 @@ describe('SearchDropdown', () => { , ) @@ -615,7 +636,7 @@ describe('SearchBoxWrapper', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) - expect(mockHandleSearchPluginTextChange).not.toHaveBeenCalled() + expect(mockHandleSearchTextChange).not.toHaveBeenCalled() }) it('should commit search when pressing Enter', () => { @@ -625,7 +646,7 @@ describe('SearchBoxWrapper', () => { fireEvent.change(input, { target: { value: 'new search' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') + expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search') }) }) 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 ff5a192ff1..e711808769 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 @@ -1,6 +1,6 @@ 'use client' -import type { PluginsSearchParams } from '../types' +import type { UnifiedSearchParams } from '../types' import { useTranslation } from '#i18n' import { useDebounce } from 'ahooks' import { useSetAtom } from 'jotai' @@ -14,14 +14,10 @@ import { import { cn } from '@/utils/classnames' import { searchModeAtom, - useActivePluginType, - useFilterPluginTags, - useMarketplaceSortValue, - useSearchPluginText, + useSearchText, } from '../atoms' -import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' -import { useMarketplacePlugins } from '../query' -import { getMarketplaceListFilterType } from '../utils' +import { useMarketplaceUnifiedSearch } from '../query' +import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils' import SearchDropdown from './search-dropdown' type SearchBoxWrapperProps = { @@ -33,41 +29,44 @@ const SearchBoxWrapper = ({ inputClassName, }: SearchBoxWrapperProps) => { const { t } = useTranslation() - const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() - const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() - const sort = useMarketplaceSortValue() + const [searchText, handleSearchTextChange] = useSearchText() const setSearchMode = useSetAtom(searchModeAtom) - const committedSearch = searchPluginText || '' + const committedSearch = searchText || '' const [draftSearch, setDraftSearch] = useState(committedSearch) const [isFocused, setIsFocused] = useState(false) const [isHoveringDropdown, setIsHoveringDropdown] = useState(false) const debouncedDraft = useDebounce(draftSearch, { wait: 300 }) const hasDraft = !!debouncedDraft.trim() - const dropdownQueryParams = useMemo(() => { + const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => { if (!hasDraft) return undefined - const filterType = getMarketplaceListFilterType(activePluginType) as PluginsSearchParams['type'] return { query: debouncedDraft.trim(), - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, - tags: filterPluginTags, - sort_by: sort.sortBy, - sort_order: sort.sortOrder, - type: filterType, - page_size: 3, + scope: ['plugins', 'templates', 'creators'], + page_size: 5, } - }, [activePluginType, debouncedDraft, filterPluginTags, hasDraft, sort.sortBy, sort.sortOrder]) + }, [debouncedDraft, hasDraft]) - const dropdownQuery = useMarketplacePlugins(dropdownQueryParams) - const dropdownPlugins = dropdownQuery.data?.pages[0]?.plugins || [] + const dropdownQuery = useMarketplaceUnifiedSearch(dropdownQueryParams) + const dropdownPlugins = useMemo( + () => (dropdownQuery.data?.plugins.items || []).map(mapUnifiedPluginToPlugin), + [dropdownQuery.data?.plugins.items], + ) + const dropdownTemplates = useMemo( + () => (dropdownQuery.data?.templates.items || []).map(mapUnifiedTemplateToTemplate), + [dropdownQuery.data?.templates.items], + ) + const dropdownCreators = useMemo( + () => (dropdownQuery.data?.creators.items || []).map(mapUnifiedCreatorToCreator), + [dropdownQuery.data?.creators.items], + ) const handleSubmit = () => { const trimmed = draftSearch.trim() if (!trimmed) return - handleSearchPluginTextChange(trimmed) + handleSearchTextChange(trimmed) setSearchMode(true) setIsFocused(false) } @@ -119,6 +118,8 @@ const SearchBoxWrapper = ({ 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 7c8612b008..3a53338cbc 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,16 +1,62 @@ +import type { Creator, Template } from '../../types' import type { Plugin } from '@/app/components/plugins/types' import { 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 { cn } from '@/utils/classnames' import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons' -import { getPluginDetailLinkInMarketplace } from '../../utils' +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 DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => ( +
+
{title}
+
{children}
+
+) + +const DropdownItem = ({ href, icon, children }: { + href: string + icon: React.ReactNode + children: React.ReactNode +}) => ( + + {icon} +
{children}
+
+) + +const IconBox = ({ shape, className, children }: { + shape: 'rounded-lg' | 'rounded-full' + className?: string + children: React.ReactNode +}) => ( +
+ {children} +
+) + +const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => ( +
+ {items.filter(Boolean).map((item, i) => ( + + {i > 0 && ·} + {typeof item === 'string' ? {item} : item} + + ))} +
+) type SearchDropdownProps = { query: string plugins: Plugin[] + templates: Template[] + creators: Creator[] onShowAll: () => void isLoading?: boolean } @@ -18,6 +64,8 @@ type SearchDropdownProps = { const SearchDropdown = ({ query, plugins, + templates, + creators, onShowAll, isLoading = false, }: SearchDropdownProps) => { @@ -25,66 +73,119 @@ const SearchDropdown = ({ const getValueFromI18nObject = useRenderI18nObject() const { categoriesMap } = useCategories(true) + const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0 + return ( -
+
- {isLoading && !plugins.length && ( + {isLoading && !hasResults && (
)} - {!!plugins.length && ( -
-
- {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] - return ( - -
+ + {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}
- )} -
-
- {TypeIcon && } - {categoryLabel} -
- · - - {t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author })} - - · - {installLabel} -
-
-
- ) - })} -
-
+ + )} + > +
{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}
+ )} +
+ ))} +
)}
diff --git a/web/app/components/plugins/marketplace/search-page/creator-card.tsx b/web/app/components/plugins/marketplace/search-page/creator-card.tsx new file mode 100644 index 0000000000..30011c2c90 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-page/creator-card.tsx @@ -0,0 +1,60 @@ +'use client' + +import type { Creator } from '../types' +import { getCreatorAvatarUrl } from '../utils' +import { useTranslation } from '#i18n' +import { getMarketplaceUrl } from '@/utils/var' + +type CreatorCardProps = { + creator: Creator +} + +const CreatorCard = ({ creator }: CreatorCardProps) => { + const { t } = useTranslation() + const href = getMarketplaceUrl(`/creators/${creator.unique_handle}`) + const displayName = creator.display_name || creator.name + + return ( + +
+
+ {displayName} +
+
+
{displayName}
+
+ @ + {creator.unique_handle} +
+
+
+ {!!creator.description && ( +
+ {creator.description} +
+ )} + {(creator.plugin_count !== undefined || creator.template_count !== undefined) && ( +
+ {creator.plugin_count || 0} + {' '} + {t('plugins', { ns: 'plugin' }).toLowerCase()} + {' · '} + {creator.template_count || 0} + {' '} + {t('templates', { ns: 'plugin' }).toLowerCase()} +
+ )} +
+ ) +} + +export default CreatorCard diff --git a/web/app/components/plugins/marketplace/search-page/index.tsx b/web/app/components/plugins/marketplace/search-page/index.tsx new file mode 100644 index 0000000000..f1179e711c --- /dev/null +++ b/web/app/components/plugins/marketplace/search-page/index.tsx @@ -0,0 +1,266 @@ +'use client' + +import type { SearchTab } from '../search-params' +import type { Creator, PluginsSearchParams, Template } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import { useTranslation } from '#i18n' +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 { PLUGIN_TYPE_SEARCH_MAP } from '../constants' +import Empty from '../empty' +import { useMarketplaceContainerScroll } from '../hooks' +import CardWrapper from '../list/card-wrapper' +import TemplateCard from '../list/template-card' +import { useMarketplaceCreators, useMarketplacePlugins, useMarketplaceTemplates } from '../query' +import SortDropdown from '../sort-dropdown' +import { getPluginFilterType, mapTemplateDetailToTemplate } from '../utils' +import CreatorCard from './creator-card' + +const PAGE_SIZE = 40 +const ZERO_WIDTH_SPACE = '\u200B' + +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 } +} + +const SearchPage = () => { + const { t } = useTranslation() + const [searchText] = useSearchText() + const debouncedQuery = useDebounce(searchText, { wait: 500 }) + const [searchTabParam, setSearchTab] = useSearchTab() + const searchTab = (searchTabParam || 'all') as SearchTab + const sort = useMarketplaceSortValue() + + const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim() + const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE) + + const pluginsParams = useMemo(() => { + if (!hasQuery) + return undefined + return { + query, + page_size: searchTab === 'all' ? 6 : PAGE_SIZE, + sort_by: sort.sortBy, + sort_order: sort.sortOrder, + type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all), + } as PluginsSearchParams + }, [hasQuery, query, searchTab, sort]) + + const templatesParams = useMemo(() => { + if (!hasQuery) + return undefined + const { sort_by, sort_order } = mapSortForTemplates(sort) + return { + query, + page_size: searchTab === 'all' ? 6 : PAGE_SIZE, + sort_by, + sort_order, + } + }, [hasQuery, query, searchTab, sort]) + + const creatorsParams = useMemo(() => { + if (!hasQuery) + return undefined + const { sort_by, sort_order } = mapSortForCreators(sort) + return { + query, + page_size: searchTab === 'all' ? 6 : PAGE_SIZE, + sort_by, + sort_order, + } + }, [hasQuery, query, searchTab, sort]) + + const fetchPlugins = searchTab === 'all' || searchTab === 'plugins' + const fetchTemplates = searchTab === 'all' || searchTab === 'templates' + const fetchCreators = searchTab === 'all' || searchTab === 'creators' + + const pluginsQuery = useMarketplacePlugins(fetchPlugins ? pluginsParams : undefined) + const templatesQuery = useMarketplaceTemplates(fetchTemplates ? templatesParams : undefined) + const creatorsQuery = useMarketplaceCreators(fetchCreators ? creatorsParams : undefined) + + const plugins = pluginsQuery.data?.pages.flatMap(p => p.plugins) ?? [] + const pluginsTotal = pluginsQuery.data?.pages[0]?.total ?? 0 + const templates = useMemo( + () => (templatesQuery.data?.pages.flatMap(p => p.templates) ?? []).map(mapTemplateDetailToTemplate), + [templatesQuery.data], + ) + const templatesTotal = templatesQuery.data?.pages[0]?.total ?? 0 + const creators = creatorsQuery.data?.pages.flatMap(p => p.creators) ?? [] + const creatorsTotal = creatorsQuery.data?.pages[0]?.total ?? 0 + + const handleScrollLoadMore = useCallback(() => { + if (searchTab === 'plugins' && pluginsQuery.hasNextPage && !pluginsQuery.isFetching) + pluginsQuery.fetchNextPage() + else if (searchTab === 'templates' && templatesQuery.hasNextPage && !templatesQuery.isFetching) + templatesQuery.fetchNextPage() + else if (searchTab === 'creators' && creatorsQuery.hasNextPage && !creatorsQuery.isFetching) + creatorsQuery.fetchNextPage() + }, [searchTab, pluginsQuery, templatesQuery, creatorsQuery]) + + useMarketplaceContainerScroll(handleScrollLoadMore) + + const tabOptions = [ + { value: 'all', text: t('marketplace.searchFilterAll', { ns: 'plugin' }), count: pluginsTotal + templatesTotal + creatorsTotal }, + { value: 'templates', text: t('templates', { ns: 'plugin' }), count: templatesTotal }, + { value: 'plugins', text: t('plugins', { ns: 'plugin' }), count: pluginsTotal }, + { value: 'creators', text: t('marketplace.searchFilterCreators', { ns: 'plugin' }), count: creatorsTotal }, + ] + + const isLoading = (fetchPlugins && pluginsQuery.isLoading) + || (fetchTemplates && templatesQuery.isLoading) + || (fetchCreators && creatorsQuery.isLoading) + const isFetchingNextPage = pluginsQuery.isFetchingNextPage + || templatesQuery.isFetchingNextPage + || creatorsQuery.isFetchingNextPage + + const renderPluginsSection = (items: Plugin[], limit?: number) => { + const toShow = limit ? items.slice(0, limit) : items + if (toShow.length === 0) + return null + return ( +
+ {toShow.map(plugin => ( + + ))} +
+ ) + } + + const renderTemplatesSection = (items: Template[], limit?: number) => { + const toShow = limit ? items.slice(0, limit) : items + if (toShow.length === 0) + return null + return ( +
+ {toShow.map(template => ( +
+ +
+ ))} +
+ ) + } + + const renderCreatorsSection = (items: Creator[], limit?: number) => { + const toShow = limit ? items.slice(0, limit) : items + if (toShow.length === 0) + return null + return ( +
+ {toShow.map(creator => ( + + ))} +
+ ) + } + + const renderAllTab = () => ( +
+ {templates.length > 0 && ( +
+

+ {t('templates', { ns: 'plugin' })} +

+ {renderTemplatesSection(templates, 6)} +
+ )} + {plugins.length > 0 && ( +
+

+ {t('plugins', { ns: 'plugin' })} +

+ {renderPluginsSection(plugins, 6)} +
+ )} + {creators.length > 0 && ( +
+

+ {t('marketplace.searchFilterCreators', { ns: 'plugin' })} +

+ {renderCreatorsSection(creators, 6)} +
+ )} + {!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && ( + + )} +
+ ) + + const renderPluginsTab = () => { + if (plugins.length === 0 && !pluginsQuery.isLoading) + return + return ( +
+ {renderPluginsSection(plugins)} +
+ ) + } + + const renderTemplatesTab = () => { + if (templates.length === 0 && !templatesQuery.isLoading) + return + return ( +
+ {renderTemplatesSection(templates)} +
+ ) + } + + const renderCreatorsTab = () => { + if (creators.length === 0 && !creatorsQuery.isLoading) + return + return ( +
+ {renderCreatorsSection(creators)} +
+ ) + } + + return ( +
+
+ setSearchTab(v as SearchTab)} + options={tabOptions} + /> + +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && ( + <> + {searchTab === 'all' && renderAllTab()} + {searchTab === 'plugins' && renderPluginsTab()} + {searchTab === 'templates' && renderTemplatesTab()} + {searchTab === 'creators' && renderCreatorsTab()} + + )} + + {isFetchingNextPage && } +
+ ) +} + +export default SearchPage diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index 32dd1743c9..5d1edeabc4 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -1,12 +1,13 @@ -import type { ActivePluginType } from './constants' import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' export type CreationType = 'plugins' | 'templates' export const marketplaceSearchParamsParsers = { - category: parseAsStringEnum(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), q: parseAsString.withDefault('').withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), creationType: parseAsStringEnum(['plugins', 'templates']).withDefault('plugins').withOptions({ history: 'replace' }), + searchTab: parseAsStringEnum(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }), } + +export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | '' diff --git a/web/app/components/plugins/marketplace/search-results-header.tsx b/web/app/components/plugins/marketplace/search-results-header.tsx index 74c223bd76..1513d8beec 100644 --- a/web/app/components/plugins/marketplace/search-results-header.tsx +++ b/web/app/components/plugins/marketplace/search-results-header.tsx @@ -1,25 +1,29 @@ 'use client' import { useTranslation } from '#i18n' -import { useSearchPluginText } from './atoms' +import { useSearchText } from './atoms' -const SearchResultsHeader = () => { +type SearchResultsHeaderProps = { + marketplaceNav?: React.ReactNode +} +const SearchResultsHeader = ({ marketplaceNav }: SearchResultsHeaderProps) => { const { t } = useTranslation('plugin') - const [searchPluginText] = useSearchPluginText() + const [searchText] = useSearchText() return ( -
-
+
+ {marketplaceNav} +
{t('marketplace.searchBreadcrumbMarketplace')} / {t('marketplace.searchBreadcrumbSearch')}
-
+
{t('marketplace.searchResultsFor')}
-
- {searchPluginText || ''} +
+ {searchText || ''}
diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 89fa7637bf..947d0486e0 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -2,24 +2,31 @@ import type { PluginsSearchParams, TemplateSearchParams } from './types' import { useDebounce } from 'ahooks' import { useSearchParams } from 'next/navigation' import { useCallback, useMemo } from 'react' -import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useActivePluginCategory, useActiveTemplateCategory, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchText } from './atoms' +import { CATEGORY_ALL } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query' -import { getCollectionsParams, getMarketplaceListFilterType, mapTemplateDetailToTemplate } from './utils' +import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils' + +const getCategory = (category: string) => { + if (category === CATEGORY_ALL) + return undefined + return category +} /** * Hook for plugins marketplace data * Only fetches plugins-related data */ -export function usePluginsMarketplaceData() { - const [searchPluginTextOriginal] = useSearchPluginText() - const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) +export function usePluginsMarketplaceData(enabled = true) { + const [searchTextOriginal] = useSearchText() + const searchText = useDebounce(searchTextOriginal, { wait: 500 }) const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + const [activePluginCategory] = useActivePluginCategory() - const collectionsQuery = useMarketplaceCollectionsAndPlugins( - getCollectionsParams(activePluginType), + const pluginsCollectionsQuery = useMarketplaceCollectionsAndPlugins( + getCollectionsParams(activePluginCategory), + { enabled }, ) const sort = useMarketplaceSortValue() @@ -28,16 +35,16 @@ export function usePluginsMarketplaceData() { if (!isSearchMode) return undefined return { - query: searchPluginText, - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + query: searchText, + category: getCategory(activePluginCategory), tags: filterPluginTags, sort_by: sort.sortBy, sort_order: sort.sortOrder, - type: getMarketplaceListFilterType(activePluginType), + type: getPluginFilterType(activePluginCategory), } - }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + }, [isSearchMode, searchText, activePluginCategory, filterPluginTags, sort]) - const pluginsQuery = useMarketplacePlugins(queryParams) + const pluginsQuery = useMarketplacePlugins(queryParams, { enabled }) const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery const handlePageChange = useCallback(() => { @@ -49,12 +56,12 @@ export function usePluginsMarketplaceData() { useMarketplaceContainerScroll(handlePageChange) return { - marketplaceCollections: collectionsQuery.data?.marketplaceCollections, - marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + pluginCollections: pluginsCollectionsQuery.data?.marketplaceCollections, + pluginCollectionPluginsMap: pluginsCollectionsQuery.data?.marketplaceCollectionPluginsMap, plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), pluginsTotal: pluginsQuery.data?.pages[0]?.total, page: pluginsQuery.data?.pages.length || 1, - isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + isLoading: pluginsCollectionsQuery.isLoading || pluginsQuery.isLoading, isFetchingNextPage, } } @@ -63,20 +70,20 @@ export function usePluginsMarketplaceData() { * Hook for templates marketplace data * Only fetches templates-related data */ -export function useTemplatesMarketplaceData() { +export function useTemplatesMarketplaceData(enabled = true) { // Reuse existing atoms for search and sort - const [searchTextOriginal] = useSearchPluginText() + const [searchTextOriginal] = useSearchText() const searchText = useDebounce(searchTextOriginal, { wait: 500 }) - const [activeCategory] = useActivePluginType() + const [activeTemplateCategory] = useActiveTemplateCategory() // Template collections query (for non-search mode) - const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates() + const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled }) // Sort value const sort = useMarketplaceSortValue() // Search mode: when there's search text or non-default category - const isSearchMode = !!searchText || (activeCategory !== PLUGIN_TYPE_SEARCH_MAP.all) + const isSearchMode = useMarketplaceSearchMode() // Build query params for search mode const queryParams = useMemo((): TemplateSearchParams | undefined => { @@ -84,14 +91,14 @@ export function useTemplatesMarketplaceData() { return undefined return { query: searchText, - categories: activeCategory === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : [activeCategory], + categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory], sort_by: sort.sortBy, sort_order: sort.sortOrder, } - }, [isSearchMode, searchText, activeCategory, sort]) + }, [isSearchMode, searchText, activeTemplateCategory, sort]) // Templates search query (for search mode) - const templatesQuery = useMarketplaceTemplates(queryParams) + const templatesQuery = useMarketplaceTemplates(queryParams, { enabled }) const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery // Pagination handler @@ -103,93 +110,42 @@ export function useTemplatesMarketplaceData() { // Scroll pagination useMarketplaceContainerScroll(handlePageChange) - // Compute flat templates list from collection map (for non-search mode) - const { collectionTemplates, collectionTemplatesTotal } = useMemo(() => { - const templateCollectionTemplatesMap = templateCollectionsQuery.data?.templateCollectionTemplatesMap - if (!templateCollectionTemplatesMap) { - return { collectionTemplates: undefined, collectionTemplatesTotal: 0 } - } - - const allTemplates = Object.values(templateCollectionTemplatesMap).flat() - // Deduplicate templates by template_id - const uniqueTemplates = allTemplates.filter( - (template, index, self) => index === self.findIndex(t => t.template_id === template.template_id), - ) - - return { - collectionTemplates: uniqueTemplates, - collectionTemplatesTotal: uniqueTemplates.length, - } - }, [templateCollectionsQuery.data?.templateCollectionTemplatesMap]) - - const searchTemplates = useMemo(() => { - const rawTemplates = templatesQuery.data?.pages.flatMap(page => page.templates) || [] - return rawTemplates.map(mapTemplateDetailToTemplate) - }, [templatesQuery.data]) - - // Return search results when in search mode, otherwise return collection data - if (isSearchMode) { - return { - isSearchMode, - templateCollections: undefined, - templateCollectionTemplatesMap: undefined, - templates: searchTemplates, - templatesTotal: templatesQuery.data?.pages[0]?.total, - page: templatesQuery.data?.pages.length || 1, - isLoading: templatesQuery.isLoading, - isFetchingNextPage, - } - } - return { - isSearchMode, templateCollections: templateCollectionsQuery.data?.templateCollections, templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap, - templates: collectionTemplates, - templatesTotal: collectionTemplatesTotal, - page: 1, - isLoading: templateCollectionsQuery.isLoading, - isFetchingNextPage: false, + templates: templatesQuery.data?.pages.flatMap(page => page.templates).map(mapTemplateDetailToTemplate), + templatesTotal: templatesQuery.data?.pages[0]?.total, + page: templatesQuery.data?.pages.length || 1, + isLoading: templateCollectionsQuery.isLoading || templatesQuery.isLoading, + isFetchingNextPage, } } +type PluginsMarketplaceData = ReturnType +type TemplatesMarketplaceData = ReturnType +type MarketplaceData + = ({ creationType: 'plugins' } & PluginsMarketplaceData) + | ({ creationType: 'templates' } & TemplatesMarketplaceData) + /** * Main hook that routes to appropriate data based on creationType * Returns either plugins or templates data based on URL parameter */ -export function useMarketplaceData() { +export function useMarketplaceData(): MarketplaceData { const searchParams = useSearchParams() const creationType = (searchParams.get('creationType') || 'plugins') as 'plugins' | 'templates' - const pluginsData = usePluginsMarketplaceData() - const templatesData = useTemplatesMarketplaceData() - + const pluginsData = usePluginsMarketplaceData(creationType === 'plugins') + const templatesData = useTemplatesMarketplaceData(creationType === 'templates') if (creationType === 'templates') { return { creationType, - isSearchMode: templatesData.isSearchMode, - // Templates-specific fields - templateCollections: templatesData.templateCollections, - templateCollectionTemplatesMap: templatesData.templateCollectionTemplatesMap, - templates: templatesData.templates, - templatesTotal: templatesData.templatesTotal, - page: templatesData.page, - isLoading: templatesData.isLoading, - isFetchingNextPage: templatesData.isFetchingNextPage, + ...templatesData, } } - // Default: plugins return { creationType, - isSearchMode: false, // plugins uses useMarketplaceSearchMode separately - // Plugins-specific fields - marketplaceCollections: pluginsData.marketplaceCollections, - marketplaceCollectionPluginsMap: pluginsData.marketplaceCollectionPluginsMap, - plugins: pluginsData.plugins, - pluginsTotal: pluginsData.pluginsTotal, - page: pluginsData.page, - isLoading: pluginsData.isLoading, - isFetchingNextPage: pluginsData.isFetchingNextPage, + ...pluginsData, } } diff --git a/web/app/components/plugins/marketplace/template-category-switch.tsx b/web/app/components/plugins/marketplace/template-category-switch.tsx new file mode 100644 index 0000000000..c07e14e5d2 --- /dev/null +++ b/web/app/components/plugins/marketplace/template-category-switch.tsx @@ -0,0 +1,78 @@ +'use client' + +import { useTranslation } from '#i18n' +import { RiFileList3Line } from '@remixicon/react' +import { useActiveTemplateCategory } from './atoms' +import CategorySwitch from './category-switch' +import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from './constants' +import { Playground } from '@/app/components/base/icons/src/vender/plugin' + +type TemplateCategorySwitchProps = { + className?: string + variant?: 'default' | 'hero' +} + +const TemplateCategorySwitch = ({ + className, + variant = 'default', +}: TemplateCategorySwitchProps) => { + const { t } = useTranslation() + const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory() + + const isHeroVariant = variant === 'hero' + + const options = [ + { + value: CATEGORY_ALL, + text: t('marketplace.templateCategory.all', { ns: 'plugin' }), + icon: isHeroVariant ? : 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 ( + + ) +} + +export default TemplateCategorySwitch diff --git a/web/app/components/plugins/marketplace/types.ts b/web/app/components/plugins/marketplace/types.ts index 6c204d1b7f..3426066333 100644 --- a/web/app/components/plugins/marketplace/types.ts +++ b/web/app/components/plugins/marketplace/types.ts @@ -6,7 +6,7 @@ export type SearchParamsFromCollection = { sort_order?: string } -export type MarketplaceCollection = { +export type PluginCollection = { name: string label: Record description: Record @@ -18,7 +18,7 @@ export type MarketplaceCollection = { } export type MarketplaceCollectionsResponse = { - collections: MarketplaceCollection[] + collections: PluginCollection[] total: number } @@ -184,3 +184,52 @@ export type TemplateSearchParams = { sort_order?: string languages?: string[] } + +// Unified search types + +export type UnifiedSearchScope = 'creators' | 'organizations' | 'plugins' | 'templates' + +export type UnifiedSearchParams = { + query: string + scope?: UnifiedSearchScope[] + page?: number + page_size?: number +} + +// Plugin item shape from /search/unified (superset of Plugin with index_id) +export type UnifiedPluginItem = Plugin & { + index_id: string +} + +// Template item shape from /search/unified (differs from TemplateDetail) +export type UnifiedTemplateItem = { + id: string + index_id: string + template_name: string + icon: string + icon_file_key: string + categories: string[] + overview: string + readme: string + partner_link: string + publisher_handle: string + publisher_type: 'individual' | 'organization' + status: string + usage_count: number + created_at: string + updated_at: string +} + +// Creator item shape from /search/unified (superset of Creator with index_id) +export type UnifiedCreatorItem = Creator & { + index_id: string +} + +export type UnifiedSearchResponse = { + data: { + creators: { items: UnifiedCreatorItem[], total: number } + organizations: { items: unknown[], total: number } + plugins: { items: UnifiedPluginItem[], total: number } + templates: { items: UnifiedTemplateItem[], total: number } + } +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 41995bfdc6..06e9686ca4 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,12 +1,19 @@ import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, - MarketplaceCollection, + Creator, + CreatorSearchParams, + PluginCollection, PluginsSearchParams, Template, TemplateCollection, TemplateDetail, TemplateSearchParams, + UnifiedCreatorItem, + UnifiedPluginItem, + UnifiedSearchParams, + UnifiedSearchResponse, + UnifiedTemplateItem, } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -21,12 +28,21 @@ type MarketplaceFetchOptions = { signal?: AbortSignal } +/** Get a string key from an item by field name (e.g. plugin_id, template_id). */ +export function getItemKeyByField(item: T, field: keyof T): string { + return String((item as Record)[field as string]) +} + export const getPluginIconInMarketplace = (plugin: Plugin) => { if (plugin.type === 'bundle') return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon` return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` } +export const getCreatorAvatarUrl = (uniqueHandle: string) => { + return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar` +} + export const getFormattedPlugin = (bundle: Plugin): Plugin => { if (bundle.type === 'bundle') { return { @@ -63,7 +79,7 @@ export const getMarketplacePluginsByCollectionId = async ( let plugins: Plugin[] = [] try { - const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({ + const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({ params: { collectionId, }, @@ -85,10 +101,10 @@ export const getMarketplaceCollectionsAndPlugins = async ( query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record = {} + let pluginCollections: PluginCollection[] = [] + let pluginCollectionPluginsMap: Record = {} try { - const marketplaceCollectionsDataJson = await marketplaceClient.collections({ + const collectionsDataJson = await marketplaceClient.plugins.collections({ query: { ...query, page: 1, @@ -97,22 +113,22 @@ export const getMarketplaceCollectionsAndPlugins = async ( }, { signal: options?.signal, }) - marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] - await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => { + pluginCollections = collectionsDataJson.data?.collections || [] + await Promise.all(pluginCollections.map(async (collection: PluginCollection) => { const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) - marketplaceCollectionPluginsMap[collection.name] = plugins + pluginCollectionPluginsMap[collection.name] = plugins })) } // eslint-disable-next-line unused-imports/no-unused-vars catch (e) { - marketplaceCollections = [] - marketplaceCollectionPluginsMap = {} + pluginCollections = [] + pluginCollectionPluginsMap = {} } return { - marketplaceCollections, - marketplaceCollectionPluginsMap, + marketplaceCollections: pluginCollections, + marketplaceCollectionPluginsMap: pluginCollectionPluginsMap, } } @@ -202,7 +218,7 @@ export const getMarketplacePlugins = async ( } = queryParams try { - const res = await marketplaceClient.searchAdvanced({ + const res = await marketplaceClient.plugins.searchAdvanced({ params: { kind: type === 'bundle' ? 'bundles' : 'plugins', }, @@ -235,7 +251,7 @@ export const getMarketplacePlugins = async ( } } -export const getMarketplaceListCondition = (pluginType: string) => { +export const getPluginCondition = (pluginType: string) => { if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) return `category=${pluginType}` @@ -248,7 +264,7 @@ export const getMarketplaceListCondition = (pluginType: string) => { return '' } -export const getMarketplaceListFilterType = (category: ActivePluginType) => { +export const getPluginFilterType = (category: ActivePluginType) => { if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined @@ -264,8 +280,8 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd } return { category, - condition: getMarketplaceListCondition(category), - type: getMarketplaceListFilterType(category), + condition: getPluginCondition(category), + type: getPluginFilterType(category), } } @@ -326,3 +342,192 @@ export const getMarketplaceTemplates = async ( } } } + +export const getMarketplaceCreators = async ( + queryParams: CreatorSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +): Promise<{ + creators: Creator[] + total: number + page: number + page_size: number +}> => { + if (!queryParams) { + return { + creators: [], + total: 0, + page: 1, + page_size: 40, + } + } + + const { + query, + sort_by, + sort_order, + categories, + page_size = 40, + } = queryParams + + try { + const res = await marketplaceClient.creators.searchAdvanced({ + body: { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + categories, + }, + }, { signal }) + + const creators = (res.data?.creators || []).map((c: Creator) => ({ + ...c, + display_name: c.display_name || c.name, + display_email: c.display_email ?? '', + social_links: c.social_links ?? [], + })) + + return { + creators, + total: res.data?.total || 0, + page: pageParam, + page_size, + } + } + catch { + return { + creators: [], + total: 0, + page: pageParam, + page_size, + } + } +} + +/** + * Map unified search plugin item to Plugin type + */ +export function mapUnifiedPluginToPlugin(item: UnifiedPluginItem): Plugin { + return { + type: item.type, + org: item.org, + name: item.name, + plugin_id: item.plugin_id, + version: item.latest_version, + latest_version: item.latest_version, + latest_package_identifier: item.latest_package_identifier, + icon: `${MARKETPLACE_API_PREFIX}/plugins/${item.org}/${item.name}/icon`, + verified: item.verification?.authorized_category === 'langgenius', + label: item.label, + brief: item.brief, + description: item.brief, + introduction: '', + repository: item.repository || '', + category: item.category as PluginCategoryEnum, + install_count: item.install_count, + endpoint: { settings: [] }, + tags: item.tags || [], + badges: item.badges || [], + verification: item.verification, + from: 'marketplace', + } +} + +/** + * Map unified search template item to Template type + */ +export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Template { + const descriptionText = item.overview || item.readme || '' + return { + template_id: item.id, + name: item.template_name, + description: { + en_US: descriptionText, + zh_Hans: descriptionText, + }, + icon: item.icon || '', + tags: item.categories || [], + author: item.publisher_handle || '', + created_at: item.created_at, + updated_at: item.updated_at, + } +} + +/** + * Map unified search creator item to Creator type + */ +export function mapUnifiedCreatorToCreator(item: UnifiedCreatorItem): Creator { + return { + email: item.email || '', + name: item.name || '', + display_name: item.display_name || item.name || '', + unique_handle: item.unique_handle || '', + display_email: '', + description: item.description || '', + avatar: item.avatar || '', + social_links: [], + status: item.status || 'active', + plugin_count: item.plugin_count, + template_count: item.template_count, + created_at: '', + updated_at: '', + } +} + +/** + * Fetch unified search results + */ +export const getMarketplaceUnifiedSearch = async ( + queryParams: UnifiedSearchParams | undefined, + signal?: AbortSignal, +): Promise => { + if (!queryParams || !queryParams.query.trim()) { + return { + creators: { items: [], total: 0 }, + organizations: { items: [], total: 0 }, + plugins: { items: [], total: 0 }, + templates: { items: [], total: 0 }, + page: 1, + page_size: queryParams?.page_size || 10, + } + } + + const { + query, + scope, + page = 1, + page_size = 10, + } = queryParams + + try { + const res = await marketplaceClient.searchUnified({ + body: { + query, + scope, + page, + page_size, + }, + }, { signal }) + + return { + creators: res.data?.creators || { items: [], total: 0 }, + organizations: res.data?.organizations || { items: [], total: 0 }, + plugins: res.data?.plugins || { items: [], total: 0 }, + templates: res.data?.templates || { items: [], total: 0 }, + page, + page_size, + } + } + catch { + return { + creators: { items: [], total: 0 }, + organizations: { items: [], total: 0 }, + plugins: { items: [], total: 0 }, + templates: { items: [], total: 0 }, + page, + page_size, + } + } +} diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 6db444f012..58ba2b296c 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -9,11 +9,11 @@ import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' -import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' +import { getPluginCondition } from '@/app/components/plugins/marketplace/utils' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useAllToolProviders } from '@/service/use-tools' -export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => { +export const useMarketplace = (searchText: string, filterPluginTags: string[]) => { const { data: toolProvidersData, isSuccess } = useAllToolProviders() const exclude = useMemo(() => { if (isSuccess) @@ -21,8 +21,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin }, [isSuccess, toolProvidersData]) const { isLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, + pluginCollections, + pluginCollectionPluginsMap, queryMarketplaceCollectionsAndPlugins, } = useMarketplaceCollectionsAndPlugins() const { @@ -35,19 +35,19 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin hasNextPage, page: pluginsPage, } = useMarketplacePlugins() - const searchPluginTextRef = useRef(searchPluginText) + const searchTextRef = useRef(searchText) const filterPluginTagsRef = useRef(filterPluginTags) useEffect(() => { - searchPluginTextRef.current = searchPluginText + searchTextRef.current = searchText filterPluginTagsRef.current = filterPluginTags - }, [searchPluginText, filterPluginTags]) + }, [searchText, filterPluginTags]) useEffect(() => { - if ((searchPluginText || filterPluginTags.length) && isSuccess) { - if (searchPluginText) { + if ((searchText || filterPluginTags.length) && isSuccess) { + if (searchText) { queryPluginsWithDebounced({ category: PluginCategoryEnum.tool, - query: searchPluginText, + query: searchText, tags: filterPluginTags, exclude, type: 'plugin', @@ -56,7 +56,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin } queryPlugins({ category: PluginCategoryEnum.tool, - query: searchPluginText, + query: searchText, tags: filterPluginTags, exclude, type: 'plugin', @@ -66,14 +66,14 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin if (isSuccess) { queryMarketplaceCollectionsAndPlugins({ category: PluginCategoryEnum.tool, - condition: getMarketplaceListCondition(PluginCategoryEnum.tool), + condition: getPluginCondition(PluginCategoryEnum.tool), exclude, type: 'plugin', }) resetPlugins() } } - }, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) + }, [searchText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) const handleScroll = useCallback((e: Event) => { const target = e.target as HTMLDivElement @@ -83,17 +83,17 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin clientHeight, } = target if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { - const searchPluginText = searchPluginTextRef.current + const searchText = searchTextRef.current const filterPluginTags = filterPluginTagsRef.current - if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) + if (hasNextPage && (!!searchText || !!filterPluginTags.length)) fetchNextPage() } }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins]) return { isLoading: isLoading || isPluginsLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, + pluginCollections, + pluginCollectionPluginsMap, plugins, handleScroll, page: Math.max(pluginsPage || 0, 1), diff --git a/web/app/components/tools/marketplace/index.spec.tsx b/web/app/components/tools/marketplace/index.spec.tsx index 493d960e2a..38fafe443f 100644 --- a/web/app/components/tools/marketplace/index.spec.tsx +++ b/web/app/components/tools/marketplace/index.spec.tsx @@ -15,8 +15,8 @@ import Marketplace from './index' const listRenderSpy = vi.fn() vi.mock('@/app/components/plugins/marketplace/list', () => ({ default: (props: { - marketplaceCollections: unknown[] - marketplaceCollectionPluginsMap: Record + pluginCollections: unknown[] + pluginCollectionPluginsMap: Record plugins?: unknown[] showInstallButton?: boolean }) => { @@ -90,8 +90,8 @@ const createPlugin = (overrides: Partial = {}): Plugin => ({ const createMarketplaceContext = (overrides: Partial> = {}) => ({ isLoading: false, - marketplaceCollections: [], - marketplaceCollectionPluginsMap: {}, + pluginCollections: [], + pluginCollectionPluginsMap: {}, plugins: [], handleScroll: vi.fn(), page: 1, @@ -110,7 +110,7 @@ describe('Marketplace', () => { const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 }) render( { }) render( { const showMarketplacePanel = vi.fn() const { container } = render( { }) => { mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({ isLoading: overrides?.isLoading ?? false, - marketplaceCollections: [], - marketplaceCollectionPluginsMap: {}, + pluginCollections: [], + pluginCollectionPluginsMap: {}, queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins, }) mockUseMarketplacePlugins.mockReturnValue({ diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index 3900a9e505..e20e2a11ff 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -11,14 +11,14 @@ import List from '@/app/components/plugins/marketplace/list' import { getMarketplaceUrl } from '@/utils/var' type MarketplaceProps = { - searchPluginText: string + searchText: string filterPluginTags: string[] isMarketplaceArrowVisible: boolean showMarketplacePanel: () => void marketplaceContext: ReturnType } const Marketplace = ({ - searchPluginText, + searchText, filterPluginTags, isMarketplaceArrowVisible, showMarketplacePanel, @@ -29,8 +29,8 @@ const Marketplace = ({ const { theme } = useTheme() const { isLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, + pluginCollections, + pluginCollectionPluginsMap, plugins, page, } = marketplaceContext @@ -79,7 +79,7 @@ const Marketplace = ({ {t('operation.in', { ns: 'common' })} @@ -100,8 +100,8 @@ const Marketplace = ({ { (!isLoading || page > 1) && ( diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 6cc089c96a..cb99f74c12 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -199,7 +199,7 @@ const ProviderList = () => {
{enable_marketplace && activeTab === 'builtin' && ( (), ) +export const searchUnifiedContract = base + .route({ + path: '/search/unified', + method: 'POST', + }) + .input( + type<{ + body: UnifiedSearchParams + }>(), + ) + .output( + type(), + ) + export const getPublisherTemplatesContract = base .route({ path: '/templates/publisher/{uniqueHandle}', @@ -387,3 +403,25 @@ export const getPublisherTemplatesContract = base data?: TemplatesListResponse }>(), ) + +export const getPublisherPluginsContract = base + .route({ + path: '/plugins/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query?: { + page?: number + page_size?: number + } + }>(), + ) + .output( + type<{ + data?: PluginsFromMarketplaceResponse + }>(), + ) diff --git a/web/contract/router.ts b/web/contract/router.ts index 2c1391b9ec..e4494ebf73 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -30,6 +30,7 @@ import { getCollectionTemplatesContract, getCreatorAvatarContract, getCreatorByHandleContract, + getPublisherPluginsContract, getPublisherTemplatesContract, getTemplateByIdContract, getTemplateCollectionContract, @@ -39,15 +40,20 @@ import { searchCreatorsAdvancedContract, searchTemplatesAdvancedContract, searchTemplatesBasicContract, + searchUnifiedContract, syncCreatorAvatarContract, syncCreatorProfileContract, templateCollectionsContract, } from './marketplace' export const marketplaceRouterContract = { - collections: collectionsContract, - collectionPlugins: collectionPluginsContract, - searchAdvanced: searchAdvancedContract, + plugins: { + collections: collectionsContract, + collectionPlugins: collectionPluginsContract, + searchAdvanced: searchAdvancedContract, + getPublisherPlugins: getPublisherPluginsContract, + }, + searchUnified: searchUnifiedContract, templateCollections: { list: templateCollectionsContract, create: createTemplateCollectionContract, diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index ad16d92c67..3f63592dd3 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -223,6 +223,14 @@ "marketplace.sortOption.recentlyUpdated": "Recently Updated", "marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.", "marketplace.templatesHeroTitle": "Create. Remix. Deploy.", + "marketplace.templateCategory.all": "All", + "marketplace.templateCategory.marketing": "Marketing", + "marketplace.templateCategory.sales": "Sales", + "marketplace.templateCategory.support": "Support", + "marketplace.templateCategory.operations": "Operations", + "marketplace.templateCategory.it": "IT", + "marketplace.templateCategory.knowledge": "Knowledge", + "marketplace.templateCategory.design": "Design", "marketplace.verifiedTip": "Verified by Dify", "marketplace.viewMore": "View more", "metadata.title": "Plugins", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 935093e09e..7b105f053d 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -223,6 +223,14 @@ "marketplace.sortOption.recentlyUpdated": "最近更新", "marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。", "marketplace.templatesHeroTitle": "创建。复刻。部署。", + "marketplace.templateCategory.all": "全部", + "marketplace.templateCategory.marketing": "营销", + "marketplace.templateCategory.sales": "销售", + "marketplace.templateCategory.support": "支持", + "marketplace.templateCategory.operations": "运营", + "marketplace.templateCategory.it": "IT", + "marketplace.templateCategory.knowledge": "知识", + "marketplace.templateCategory.design": "设计", "marketplace.verifiedTip": "此插件由 Dify 认证", "marketplace.viewMore": "查看更多", "metadata.title": "插件",