Compare commits

...

68 Commits

Author SHA1 Message Date
Stephen Zhou
197d8cec10 Merge branch 'main' into feat/marketplace-template 2026-04-01 14:51:26 +08:00
Stephen Zhou
b7549faacb fix test 2026-04-01 12:06:17 +08:00
autofix-ci[bot]
5bbd268fc1 [autofix.ci] apply automated fixes 2026-04-01 03:23:15 +00:00
Stephen Zhou
796ee3fee4 Merge branch 'main' into feat/marketplace-template 2026-04-01 11:20:07 +08:00
Stephen Zhou
c52f18f205 suppress all and fix typecheck 2026-04-01 11:18:32 +08:00
autofix-ci[bot]
99cb5ea8c2 [autofix.ci] apply automated fixes 2026-04-01 02:27:32 +00:00
Stephen Zhou
52a9905079 add deps 2026-04-01 10:23:41 +08:00
Stephen Zhou
ae7f8a7bde Merge branch 'main' into feat/marketplace-template 2026-04-01 10:22:33 +08:00
yessenia
f491d9daba refactor: simplify PortalToFollowElemContent structure in SubmitRequestDropdown for improved readability 2026-03-09 15:54:44 +08:00
yessenia
5c31468567 refactor: remove carouselCollectionNames prop from CollectionHeader and CollectionList components for improved clarity 2026-03-09 15:19:03 +08:00
yessenia
22d22f2b77 feat: encode publisher handle and template name in marketplace URLs for improved URL safety 2026-03-09 13:52:52 +08:00
yessenia
98449de4f6 feat: enhance carousel component to support overlay content and pagination functionality 2026-03-06 18:04:51 +08:00
yessenia
237eead38c feat: add localized empty text support to CollectionList component 2026-03-04 17:44:18 +08:00
yessenia
61b6b838de feat: add utility functions for building search parameters and marketplace URLs 2026-03-04 15:20:08 +08:00
yessenia
7934b0222d feat: add optional version field to Template type and remove unused version parsing function 2026-03-03 19:33:49 +08:00
yessenia
39f4e205a8 feat: add version parsing function to extract version from DSL content 2026-03-03 19:06:37 +08:00
Stephen Zhou
2dba133d4d refactor: move latext preprocess in 2026-03-02 11:17:07 +08:00
yessenia
ccb34e1020 feat: implement empty state for search dropdown with localized messages 2026-02-14 14:09:51 +08:00
yessenia
dc4d3de533 refactor: update language filter labels for consistency and localization 2026-02-14 13:16:14 +08:00
yessenia
efb35a97a1 feat: enhance FlatList component to support localized empty states for templates 2026-02-14 03:27:43 +08:00
Joel
0645eaeef9 feat: support language filter 2026-02-13 14:27:19 +08:00
yessenia
f49e8954d0 fix: update search box styling for better responsiveness 2026-02-13 13:43:39 +08:00
Stephen Zhou
a6fd5994ad fix: search for main app 2026-02-13 11:51:44 +08:00
Stephen Zhou
c3f52f8fa0 fix: skip ssr for main app 2026-02-13 11:41:29 +08:00
Stephen Zhou
b9ddd7047c fix: use query for main app 2026-02-13 11:39:31 +08:00
Joel
600c373ef2 fix: author page include source 2026-02-13 11:13:49 +08:00
Joel
90c734cc93 fix: market searc
h include source
2026-02-13 10:32:42 +08:00
yessenia
5f827be44f feat: add new SVG icons for PluginHeaderBg and TemplateHeaderBg 2026-02-13 03:26:09 +08:00
yessenia
eff202834c fix: update template category labels and improve path handling 2026-02-13 02:05:08 +08:00
yessenia
0f4b578462 fix: update creator links to use singular form 2026-02-12 23:12:38 +08:00
Stephen Zhou
e4f5c8f710 feat: only prefetch for first load 2026-02-12 22:09:14 +08:00
Stephen Zhou
7ca4b7f3f9 feat: prefetch 2026-02-12 21:42:03 +08:00
Stephen Zhou
b1722ba53c refactor: params 2026-02-12 18:55:23 +08:00
Stephen Zhou
594516da25 fix: scroll back in short height 2026-02-12 18:27:24 +08:00
Joel
2cc86ae8cd chore: fix with source problem 2026-02-12 18:09:21 +08:00
Stephen Zhou
27933ed4ae fix: title only show after scroll 2026-02-12 17:44:40 +08:00
Joel
ad1ebd9bbc fix: org avatar not show 2026-02-12 16:12:25 +08:00
Joel
791289dcf5 feat: public type 2026-02-12 15:37:53 +08:00
Stephen Zhou
5d5a842f37 fix: navigate for plugin category 2026-02-12 15:05:52 +08:00
Stephen Zhou
2d3e244a1f refactor: use path instead of query 2026-02-12 14:57:43 +08:00
yessenia
0db446b8ea feat: add template icon URL utility and integrate AppIcon component in template card and search dropdown for improved icon rendering 2026-02-12 01:41:58 +08:00
yessenia
8108c21d5b feat: implement carousel column layout and adjust grid display limit in marketplace components for improved visual organization 2026-02-12 00:48:05 +08:00
yessenia
26fe8c1cc5 feat: enhance marketplace components with new template card features, improved navigation options, and updated translations for better user experience 2026-02-11 23:49:15 +08:00
yessenia
1f724d0f33 refactor: streamline ListTopInfo component and enhance category handling in marketplace for improved clarity and performance 2026-02-11 15:35:06 +08:00
yessenia
8e788714e4 fix: update search placeholder text in SearchBoxWrapper for clarity in marketplace 2026-02-11 12:51:27 +08:00
yessenia
5f6f9ed517 feat: add search filters for categories, languages, types, and tags in marketplace components to enhance user search experience 2026-02-11 12:35:13 +08:00
yessenia
7b41fc4d64 feat: implement enhanced sorting and category management in marketplace components for improved user experience 2026-02-11 12:15:00 +08:00
yessenia
36f42ec0a9 refactor: optimize scroll progress handling in Description component for smoother user experience 2026-02-11 05:03:26 +08:00
yessenia
2b62862467 feat: add 'noCreatorFound' message to multiple language files for improved user feedback in marketplace 2026-02-11 04:48:09 +08:00
yessenia
72e92be0cb fix: update showViewMore logic in CollectionHeader to improve visibility conditions for marketplace collections 2026-02-11 04:40:05 +08:00
yessenia
4961242a8a feat: enhance carousel layout and search functionality in marketplace components 2026-02-11 04:31:55 +08:00
yessenia
f348258a45 feat: add 'noTemplateFound' message to multiple language files for improved user feedback in marketplace 2026-02-11 04:11:48 +08:00
yessenia
6ef87550e6 feat: enhance templates marketplace with new sorting options and improved template data structure 2026-02-11 03:40:41 +08:00
yessenia
5c6da34539 feat: introduce creation type management in marketplace components for improved data handling 2026-02-10 21:42:42 +08:00
yessenia
9f8289b185 feat: implement category switch components for marketplace with hero and default variants 2026-02-10 20:57:53 +08:00
yessenia
56c5739e4e feat: add unit tests for getValidatedPluginCategory and enhance search box functionality 2026-02-10 20:19:03 +08:00
yessenia
b241122cf7 refactor: update marketplace components to use unified terminology and improve search functionality 2026-02-10 16:38:29 +08:00
yessenia
984992d0fd feat: add Plugin icon component and integrate it into the navigation operations 2026-02-09 16:37:19 +08:00
yessenia
b9cd625c53 feat: add Playground icon component and integrate it into the navigation operations 2026-02-09 16:30:12 +08:00
yessenia
f96fbbe03a feat: replace SearchBox with Input component in SearchBoxWrapper for improved search functionality 2026-02-09 15:45:28 +08:00
yessenia
9a698eaad9 feat: introduce CardTags component for displaying tags in cards 2026-02-09 15:45:27 +08:00
yessenia
6329647f3b feat: add disableOrgLink prop to Card component and update OrgInfo to conditionally render organization link 2026-02-09 15:45:07 +08:00
yessenia
063459599c feat: enhance templates marketplace with search functionality and template mapping 2026-02-09 15:45:06 +08:00
yessenia
a59023f75b feat: add templates marketplace functionality 2026-02-09 15:45:06 +08:00
yessenia
41c1d981a1 feat: plugin button 2026-02-09 15:45:06 +08:00
yessenia
3f5037f911 feat: layout opt 2026-02-09 15:44:33 +08:00
yessenia
cbbb05c189 feat: layout opt 2026-02-09 15:44:33 +08:00
yessenia
1ce8c43e2c feat: marketplace layout opt 2026-02-09 15:44:32 +08:00
138 changed files with 7349 additions and 2374 deletions

63
pnpm-lock.yaml generated
View File

@@ -402,6 +402,9 @@ catalogs:
mitt:
specifier: 3.0.1
version: 3.0.1
motion:
specifier: 12.38.0
version: 12.38.0
negotiator:
specifier: 1.0.0
version: 1.0.0
@@ -870,6 +873,9 @@ importers:
mitt:
specifier: 'catalog:'
version: 3.0.1
motion:
specifier: 'catalog:'
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
negotiator:
specifier: 'catalog:'
version: 1.0.0
@@ -6091,6 +6097,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -7111,6 +7131,26 @@ packages:
moo-color@1.0.3:
resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
motion@12.38.0:
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -14226,6 +14266,15 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fs-constants@1.0.0:
optional: true
@@ -15551,6 +15600,20 @@ snapshots:
dependencies:
color-name: 1.1.4
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
mrmime@2.0.1: {}
ms@2.1.3: {}

View File

@@ -3,7 +3,7 @@ minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
'@parcel/watcher': false
"@parcel/watcher": false
canvas: false
esbuild: false
sharp: false
@@ -202,6 +202,7 @@ catalog:
mermaid: 11.13.0
mime: 4.1.0
mitt: 3.0.1
motion: 12.38.0
negotiator: 1.0.0
next: 16.2.1
next-themes: 0.4.6

View File

@@ -75,11 +75,10 @@ vi.mock('@/app/components/plugins/card/base/description', () => ({
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
default: ({ orgName, downloadCount }: { orgName: string, downloadCount?: number }) => (
<div data-testid="org-info">
{orgName}
/
{packageName}
{typeof downloadCount === 'number' ? ` · ${downloadCount}` : null}
</div>
),
}))
@@ -124,7 +123,7 @@ describe('Plugin Card Rendering Integration', () => {
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius')
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
})

View File

@@ -6,7 +6,7 @@ const PluginList = () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
marketplace={<Marketplace />}
/>
)
}

View File

@@ -14,6 +14,7 @@ type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
overlay?: React.ReactNode
}
type CarouselContextValue = {
@@ -49,7 +50,7 @@ type TCarousel = {
>
const Carousel: TCarousel = React.forwardRef(
({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => {
({ orientation = 'horizontal', opts, plugins, overlay, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
plugins,
@@ -115,14 +116,19 @@ const Carousel: TCarousel = React.forwardRef(
}}
>
<div
ref={carouselRef}
// onKeyDownCapture={handleKeyDown}
className={cn('relative overflow-hidden', className)}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
{overlay}
<div
ref={carouselRef}
className="overflow-hidden [border-radius:inherit]"
>
{children}
</div>
</div>
</CarouselContext.Provider>
)

View File

@@ -0,0 +1,24 @@
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.2" clip-path="url(#clip0_20862_53031)">
<g filter="url(#filter0_d_20862_53031)">
<path d="M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z" fill="#F2F4F7"/>
<path d="M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z" stroke="white" stroke-width="2"/>
</g>
</g>
<defs>
<filter id="filter0_d_20862_53031" x="31.151" y="59.719" width="525.576" height="514.866" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_20862_53031"/>
<feOffset dy="32"/>
<feGaussianBlur stdDeviation="32"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_20862_53031"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_20862_53031" result="shape"/>
</filter>
<clipPath id="clip0_20862_53031">
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,26 @@
<svg width="588" height="588" viewBox="0 0 588 588" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.2" clip-path="url(#clip0_21509_19682)">
<g filter="url(#filter0_d_21509_19682)">
<path d="M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z" fill="#F2F4F7"/>
<path d="M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z" fill="#F2F4F7"/>
<path d="M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z" fill="#F2F4F7"/>
<path d="M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z" stroke="white" stroke-width="2"/>
</g>
</g>
<defs>
<filter id="filter0_d_21509_19682" x="55.7732" y="80.6057" width="443.312" height="461.277" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="12" operator="erode" in="SourceAlpha" result="effect1_dropShadow_21509_19682"/>
<feOffset dy="32"/>
<feGaussianBlur stdDeviation="32"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_21509_19682"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_21509_19682" result="shape"/>
</filter>
<clipPath id="clip0_21509_19682">
<rect width="480" height="480" fill="white" transform="translate(0 124.233) rotate(-15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 658 B

View File

@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 6.66675H17.5V15.8334H2.5V6.66675Z" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16675 6.66659V3.33325H8.33341V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6667 6.66659V3.33325H15.8334V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

View File

@@ -0,0 +1,183 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "588",
"height": "588",
"viewBox": "0 0 588 588",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"opacity": "0.2",
"clip-path": "url(#clip0_20862_53031)"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"filter": "url(#filter0_d_20862_53031)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M204.231 152.332L201.643 142.673C194.496 115.999 210.326 88.5823 236.999 81.4353C263.672 74.2882 291.089 90.1173 298.236 116.791L300.824 126.45L407.076 97.9798C417.745 95.1209 428.712 101.453 431.571 112.122L452.276 189.396C453.706 194.731 450.539 200.214 445.205 201.643C418.532 208.79 402.703 236.208 409.85 262.881C416.997 289.554 444.414 305.383 471.087 298.236C476.421 296.807 481.905 299.973 483.335 305.307L504.04 382.581C506.899 393.251 500.568 404.217 489.898 407.076L180.802 489.898C170.132 492.757 159.166 486.426 156.307 475.756L83.8375 205.297C80.9787 194.628 87.3104 183.661 97.9796 180.802L204.231 152.332Z",
"fill": "#F2F4F7"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M237.257 82.4012C263.397 75.3971 290.266 90.9096 297.27 117.049L300.117 127.675L407.335 98.9457C417.471 96.2297 427.889 102.245 430.605 112.381L451.31 189.655C452.597 194.456 449.747 199.391 444.946 200.677C417.74 207.967 401.594 235.933 408.884 263.139C416.174 290.346 444.139 306.492 471.346 299.202C476.146 297.916 481.082 300.766 482.369 305.566L503.074 382.84C505.79 392.976 499.775 403.394 489.639 406.11L180.543 488.932C170.407 491.648 159.989 485.633 157.273 475.497L84.8034 205.038C82.0875 194.902 88.1027 184.484 98.2384 181.768L205.456 153.039L202.609 142.414C195.605 116.274 211.118 89.4053 237.257 82.4012Z",
"stroke": "white",
"stroke-width": "2"
},
"children": []
}
]
}
]
},
{
"type": "element",
"name": "defs",
"attributes": {},
"children": [
{
"type": "element",
"name": "filter",
"attributes": {
"id": "filter0_d_20862_53031",
"x": "31.151",
"y": "59.719",
"width": "525.576",
"height": "514.866",
"filterUnits": "userSpaceOnUse",
"color-interpolation-filters": "sRGB"
},
"children": [
{
"type": "element",
"name": "feFlood",
"attributes": {
"flood-opacity": "0",
"result": "BackgroundImageFix"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"in": "SourceAlpha",
"type": "matrix",
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
"result": "hardAlpha"
},
"children": []
},
{
"type": "element",
"name": "feMorphology",
"attributes": {
"radius": "12",
"operator": "erode",
"in": "SourceAlpha",
"result": "effect1_dropShadow_20862_53031"
},
"children": []
},
{
"type": "element",
"name": "feOffset",
"attributes": {
"dy": "32"
},
"children": []
},
{
"type": "element",
"name": "feGaussianBlur",
"attributes": {
"stdDeviation": "32"
},
"children": []
},
{
"type": "element",
"name": "feComposite",
"attributes": {
"in2": "hardAlpha",
"operator": "out"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"type": "matrix",
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"mode": "normal",
"in2": "BackgroundImageFix",
"result": "effect1_dropShadow_20862_53031"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"mode": "normal",
"in": "SourceGraphic",
"in2": "effect1_dropShadow_20862_53031",
"result": "shape"
},
"children": []
}
]
},
{
"type": "element",
"name": "clipPath",
"attributes": {
"id": "clip0_20862_53031"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"width": "480",
"height": "480",
"fill": "white",
"transform": "translate(0 124.233) rotate(-15)"
},
"children": []
}
]
}
]
}
]
},
"name": "PluginHeaderBg"
}

View File

@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './PluginHeaderBg.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'PluginHeaderBg'
export default Icon

View File

@@ -0,0 +1,201 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "588",
"height": "588",
"viewBox": "0 0 588 588",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"opacity": "0.2",
"clip-path": "url(#clip0_21509_19682)"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"filter": "url(#filter0_d_21509_19682)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M346.36 300.589C389.034 289.159 432.902 314.489 444.34 357.157C455.774 399.831 430.445 443.698 387.771 455.137C345.095 466.572 301.227 441.245 289.792 398.568C278.362 355.895 303.687 312.023 346.36 300.589Z",
"fill": "#F2F4F7"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M116.295 221.279C122.148 217.181 129.755 216.517 136.23 219.537L261.798 278.096C268.274 281.114 272.666 287.369 273.288 294.489C273.908 301.604 270.669 308.514 264.818 312.611L151.323 392.076C145.47 396.175 137.869 396.858 131.393 393.838C124.917 390.819 120.544 384.556 119.922 377.439L107.85 239.415C107.227 232.299 110.444 225.378 116.295 221.279Z",
"fill": "#F2F4F7"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M283.278 160.623C283.279 127.486 310.148 100.606 343.285 100.606L383.281 100.606C416.416 100.607 443.279 127.482 443.279 160.618L443.279 200.613C443.28 233.747 416.42 260.604 383.286 260.607L343.272 260.612C310.138 260.609 283.28 233.752 283.278 200.619L283.278 160.623Z",
"fill": "#F2F4F7"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M346.619 301.555C388.759 290.268 432.079 315.281 443.374 357.416C454.666 399.557 429.653 442.875 387.513 454.171C345.37 465.464 302.05 440.453 290.758 398.31C279.471 356.169 304.479 312.846 346.619 301.555ZM116.869 222.099C122.429 218.205 129.656 217.574 135.808 220.443L261.376 279.002C267.529 281.87 271.701 287.814 272.291 294.576C272.88 301.333 269.804 307.898 264.245 311.791L150.75 391.257C145.361 395.03 138.416 395.757 132.395 393.19L131.815 392.931C125.665 390.064 121.51 384.115 120.918 377.353L108.846 239.329C108.254 232.567 111.311 225.992 116.869 222.099ZM284.278 160.623C284.28 128.038 310.701 101.606 343.285 101.606L383.281 101.606C415.864 101.608 442.279 128.034 442.28 160.618L442.28 200.614C442.28 233.195 415.868 259.604 383.286 259.607L343.272 259.612C310.691 259.61 284.281 233.2 284.278 200.619L284.278 160.623Z",
"stroke": "white",
"stroke-width": "2"
},
"children": []
}
]
}
]
},
{
"type": "element",
"name": "defs",
"attributes": {},
"children": [
{
"type": "element",
"name": "filter",
"attributes": {
"id": "filter0_d_21509_19682",
"x": "55.7732",
"y": "80.6057",
"width": "443.312",
"height": "461.277",
"filterUnits": "userSpaceOnUse",
"color-interpolation-filters": "sRGB"
},
"children": [
{
"type": "element",
"name": "feFlood",
"attributes": {
"flood-opacity": "0",
"result": "BackgroundImageFix"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"in": "SourceAlpha",
"type": "matrix",
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
"result": "hardAlpha"
},
"children": []
},
{
"type": "element",
"name": "feMorphology",
"attributes": {
"radius": "12",
"operator": "erode",
"in": "SourceAlpha",
"result": "effect1_dropShadow_21509_19682"
},
"children": []
},
{
"type": "element",
"name": "feOffset",
"attributes": {
"dy": "32"
},
"children": []
},
{
"type": "element",
"name": "feGaussianBlur",
"attributes": {
"stdDeviation": "32"
},
"children": []
},
{
"type": "element",
"name": "feComposite",
"attributes": {
"in2": "hardAlpha",
"operator": "out"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"type": "matrix",
"values": "0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.14 0"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"mode": "normal",
"in2": "BackgroundImageFix",
"result": "effect1_dropShadow_21509_19682"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"mode": "normal",
"in": "SourceGraphic",
"in2": "effect1_dropShadow_21509_19682",
"result": "shape"
},
"children": []
}
]
},
{
"type": "element",
"name": "clipPath",
"attributes": {
"id": "clip0_21509_19682"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"width": "480",
"height": "480",
"fill": "white",
"transform": "translate(0 124.233) rotate(-15)"
},
"children": []
}
]
}
]
}
]
},
"name": "TemplateHeaderBg"
}

View File

@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './TemplateHeaderBg.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'TemplateHeaderBg'
export default Icon

View File

@@ -1,6 +1,8 @@
export { default as Google } from './Google'
export { default as PartnerDark } from './PartnerDark'
export { default as PartnerLight } from './PartnerLight'
export { default as PluginHeaderBg } from './PluginHeaderBg'
export { default as TemplateHeaderBg } from './TemplateHeaderBg'
export { default as VerifiedDark } from './VerifiedDark'
export { default as VerifiedLight } from './VerifiedLight'
export { default as WebReader } from './WebReader'

View File

@@ -0,0 +1,50 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linejoin": "round"
},
"children": []
}
]
},
"name": "Playground"
}

View File

@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Playground.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Playground'
export default Icon

View File

@@ -0,0 +1,53 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "20",
"height": "20",
"viewBox": "0 0 20 20",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2.5 6.66675H17.5V15.8334H2.5V6.66675Z",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M4.16675 6.66659V3.33325H8.33341V6.66659",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M11.6667 6.66659V3.33325H15.8334V6.66659",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
"name": "Plugin"
}

View File

@@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Plugin.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Plugin'
export default Icon

View File

@@ -1,3 +1,5 @@
export { default as BoxSparkleFill } from './BoxSparkleFill'
export { default as LeftCorner } from './LeftCorner'
export { default as Playground } from './Playground'
export { default as Plugin } from './Plugin'
export { default as Trigger } from './Trigger'

View File

@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />

View File

@@ -41,6 +41,13 @@ export const preprocessThinkTag = (content: string) => {
])(content)
}
export const preprocessMarkdownContent = (content: string) => {
return flow([
preprocessThinkTag,
preprocessLaTeX,
])(content)
}
/**
* Transforms a URI for use in react-markdown, ensuring security and compatibility.
* This function is designed to work with react-markdown v9+ which has stricter

View File

@@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

@@ -69,8 +69,8 @@ const InstallFromMarketplace = ({
{
!isAllPluginsLoading && !collapse && (
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton
cardContainerClassName="grid grid-cols-2 gap-2"

View File

@@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CardMoreInfo from '../card-more-info'
vi.mock('../base/download-count', () => ({
default: ({ downloadCount }: { downloadCount: number }) => (
<span data-testid="download-count">{downloadCount}</span>
),
}))
describe('CardMoreInfo', () => {
it('renders tags with # prefix', () => {
render(<CardMoreInfo tags={['search', 'agent']} />)
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('agent')).toBeInTheDocument()
// # prefixes
const hashmarks = screen.getAllByText('#')
expect(hashmarks).toHaveLength(2)
})
it('renders download count when provided', () => {
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
expect(screen.getByTestId('download-count')).toHaveTextContent('1000')
})
it('does not render download count when undefined', () => {
render(<CardMoreInfo tags={['tag1']} />)
expect(screen.queryByTestId('download-count')).not.toBeInTheDocument()
})
it('renders separator between download count and tags', () => {
render(<CardMoreInfo downloadCount={500} tags={['test']} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('does not render separator when no tags', () => {
render(<CardMoreInfo downloadCount={500} tags={[]} />)
expect(screen.queryByText('·')).not.toBeInTheDocument()
})
it('does not render separator when no download count', () => {
render(<CardMoreInfo tags={['tag1']} />)
expect(screen.queryByText('·')).not.toBeInTheDocument()
})
it('handles empty tags array', () => {
const { container } = render(<CardMoreInfo tags={[]} />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@@ -181,7 +181,7 @@ describe('Card', () => {
render(<Card payload={plugin} />)
expect(screen.getByText('my-org')).toBeInTheDocument()
expect(screen.getByText('my-plugin')).toBeInTheDocument()
expect(screen.queryByText('my-plugin')).not.toBeInTheDocument()
})
it('should render plugin icon', () => {
@@ -596,7 +596,7 @@ describe('Card', () => {
render(<Card payload={plugin} />)
expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument()
expect(screen.getByText('org<script>alert(1)</script>')).toBeInTheDocument()
})
it('should handle very long title', () => {

View File

@@ -2,6 +2,12 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DownloadCount from '../download-count'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key === 'marketplace.installs' ? 'installs' : key,
}),
}))
vi.mock('@/utils/format', () => ({
formatNumber: (n: number) => {
if (n >= 1000)
@@ -13,16 +19,16 @@ vi.mock('@/utils/format', () => ({
describe('DownloadCount', () => {
it('renders formatted download count', () => {
render(<DownloadCount downloadCount={1500} />)
expect(screen.getByText('1.5k')).toBeInTheDocument()
expect(screen.getByText('1.5k installs')).toBeInTheDocument()
})
it('renders small numbers directly', () => {
render(<DownloadCount downloadCount={42} />)
expect(screen.getByText('42')).toBeInTheDocument()
expect(screen.getByText('42 installs')).toBeInTheDocument()
})
it('renders zero download count', () => {
render(<DownloadCount downloadCount={0} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('0 installs')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { RiInstallLine } from '@remixicon/react'
import { useTranslation } from '#i18n'
import * as React from 'react'
import { formatNumber } from '@/utils/format'
@@ -9,10 +9,13 @@ type Props = {
const DownloadCountComponent = ({
downloadCount,
}: Props) => {
const { t } = useTranslation('plugin')
return (
<div className="flex items-center space-x-1 text-text-tertiary">
<RiInstallLine className="h-3 w-3 shrink-0" />
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
<div className="system-xs-regular text-text-tertiary">
{formatNumber(downloadCount)}
{' '}
{t('marketplace.installs')}
</div>
)
}

View File

@@ -1,10 +1,14 @@
import Link from 'next/link'
import { cn } from '@/utils/classnames'
import DownloadCount from './download-count'
type Props = {
className?: string
orgName?: string
packageName: string
packageName?: string
packageNameClassName?: string
downloadCount?: number
linkToOrg?: boolean
}
const OrgInfo = ({
@@ -12,7 +16,42 @@ const OrgInfo = ({
orgName,
packageName,
packageNameClassName,
downloadCount,
linkToOrg = true,
}: Props) => {
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
if (downloadCount !== undefined) {
return (
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
{orgName && (
<span className="shrink-0">
<span className="mr-1 text-text-tertiary">by</span>
{linkToOrg
? (
<Link
href={`/creator/${orgName}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-text-secondary hover:underline"
onClick={e => e.stopPropagation()}
>
{orgName}
</Link>
)
: (
<span className="text-text-tertiary">
{orgName}
</span>
)}
</span>
)}
<span className="shrink-0">·</span>
<DownloadCount downloadCount={downloadCount} />
</div>
)
}
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
return (
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
{orgName && (
@@ -21,9 +60,11 @@ const OrgInfo = ({
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
</>
)}
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
{packageName && (
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
)}
</div>
)
}

View File

@@ -1,40 +0,0 @@
import * as React from 'react'
import DownloadCount from './base/download-count'
type Props = {
downloadCount?: number
tags: string[]
}
const CardMoreInfoComponent = ({
downloadCount,
tags,
}: Props) => {
return (
<div className="flex h-5 items-center">
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
{tags && tags.length > 0 && (
<>
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
{tags.map(tag => (
<div
key={tag}
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
title={`# ${tag}`}
>
<span className="text-text-quaternary">#</span>
<span className="truncate text-text-tertiary">{tag}</span>
</div>
))}
</div>
</>
)}
</div>
)
}
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
const CardMoreInfo = React.memo(CardMoreInfoComponent)
export default CardMoreInfo

View File

@@ -0,0 +1,34 @@
import { RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
type Props = {
tags: string[]
}
const CardTagsComponent = ({
tags,
}: Props) => {
return (
<div className="mt-2 flex min-h-[20px] items-center gap-1">
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1 overflow-hidden">
{tags.slice(0, 2).map(tag => (
<span
key={tag}
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
title={tag}
>
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
</span>
))}
</div>
)}
</div>
)
}
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
const CardTags = React.memo(CardTagsComponent)
export default CardTags

View File

@@ -34,6 +34,7 @@ export type Props = {
isLoading?: boolean
loadingFileName?: string
limitedInstall?: boolean
disableOrgLink?: boolean
}
const Card = ({
@@ -48,12 +49,13 @@ const Card = ({
isLoading = false,
loadingFileName,
limitedInstall = false,
disableOrgLink = false,
}: Props) => {
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from, install_count } = payload
const { theme } = useTheme()
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },
@@ -93,7 +95,8 @@ const Card = ({
<OrgInfo
className="mt-0.5"
orgName={org}
packageName={name}
downloadCount={install_count}
linkToOrg={!disableOrgLink}
/>
</div>
</div>

View File

@@ -4,16 +4,32 @@ import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import {
useActivePluginType,
useActivePluginCategory,
useFilterPluginTags,
useMarketplaceMoreClick,
useMarketplacePluginSort,
useMarketplacePluginSortValue,
useMarketplaceSearchMode,
useMarketplaceSort,
useMarketplaceSortValue,
useSearchPluginText,
useSetMarketplaceSort,
useSearchText,
useSetMarketplacePluginSort,
} from '../atoms'
import { DEFAULT_SORT } from '../constants'
import { DEFAULT_PLUGIN_SORT } from '../constants'
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
const createWrapper = (searchParams = '') => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
@@ -30,28 +46,30 @@ const createWrapper = (searchParams = '') => {
describe('Marketplace sort atoms', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return default sort value from useMarketplaceSort', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
const { result } = renderHook(() => useMarketplacePluginSort(), { wrapper })
expect(result.current[0]).toEqual(DEFAULT_SORT)
expect(result.current[0]).toEqual(DEFAULT_PLUGIN_SORT)
expect(typeof result.current[1]).toBe('function')
})
it('should return default sort value from useMarketplaceSortValue', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
const { result } = renderHook(() => useMarketplacePluginSortValue(), { wrapper })
expect(result.current).toEqual(DEFAULT_SORT)
expect(result.current).toEqual(DEFAULT_PLUGIN_SORT)
})
it('should return setter from useSetMarketplaceSort', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
setSort: useSetMarketplaceSort(),
sortValue: useMarketplaceSortValue(),
setSort: useSetMarketplacePluginSort(),
sortValue: useMarketplacePluginSortValue(),
}), { wrapper })
act(() => {
@@ -63,7 +81,7 @@ describe('Marketplace sort atoms', () => {
it('should update sort value via useMarketplaceSort setter', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
const { result } = renderHook(() => useMarketplacePluginSort(), { wrapper })
act(() => {
result.current[1]({ sortBy: 'created_at', sortOrder: 'ASC' })
@@ -73,14 +91,16 @@ describe('Marketplace sort atoms', () => {
})
})
describe('useSearchPluginText', () => {
describe('useSearchText', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return empty string as default', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
const { result } = renderHook(() => useSearchText(), { wrapper })
expect(result.current[0]).toBe('')
expect(typeof result.current[1]).toBe('function')
@@ -88,14 +108,14 @@ describe('useSearchPluginText', () => {
it('should parse q from search params', () => {
const { wrapper } = createWrapper('?q=hello')
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
const { result } = renderHook(() => useSearchText(), { wrapper })
expect(result.current[0]).toBe('hello')
})
it('should expose a setter function for search text', async () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
const { result } = renderHook(() => useSearchText(), { wrapper })
await act(async () => {
result.current[1]('search term')
@@ -105,21 +125,23 @@ describe('useSearchPluginText', () => {
})
})
describe('useActivePluginType', () => {
describe('useActivePluginCategory', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return "all" as default category', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useActivePluginType(), { wrapper })
const { result } = renderHook(() => useActivePluginCategory(), { wrapper })
expect(result.current[0]).toBe('all')
})
it('should parse category from search params', () => {
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useActivePluginType(), { wrapper })
const { result } = renderHook(() => useActivePluginCategory(), { wrapper })
expect(result.current[0]).toBe('tool')
})
@@ -128,6 +150,8 @@ describe('useActivePluginType', () => {
describe('useFilterPluginTags', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return empty array as default', () => {
@@ -148,6 +172,8 @@ describe('useFilterPluginTags', () => {
describe('useMarketplaceSearchMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return false when no search text, no tags, and category has collections (all)', () => {
@@ -161,7 +187,7 @@ describe('useMarketplaceSearchMode', () => {
const { wrapper } = createWrapper('?q=test&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
expect(result.current).toBeTruthy()
})
it('should return true when tags are present', () => {
@@ -189,6 +215,8 @@ describe('useMarketplaceSearchMode', () => {
describe('useMarketplaceMoreClick', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should return a callback function', () => {
@@ -202,8 +230,8 @@ describe('useMarketplaceMoreClick', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
searchText: useSearchPluginText()[0],
sort: useMarketplacePluginSortValue(),
searchText: useSearchText()[0],
}), { wrapper })
const sortBefore = result.current.sort
@@ -222,7 +250,7 @@ describe('useMarketplaceMoreClick', () => {
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
sort: useMarketplacePluginSortValue(),
}), { wrapper })
act(() => {
@@ -240,13 +268,13 @@ describe('useMarketplaceMoreClick', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
sort: useMarketplacePluginSortValue(),
}), { wrapper })
act(() => {
result.current.handleMoreClick({})
})
expect(result.current.sort).toEqual(DEFAULT_SORT)
expect(result.current.sort).toEqual(DEFAULT_PLUGIN_SORT)
})
})

View File

@@ -42,8 +42,10 @@ const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
plugins: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
},
}))
@@ -90,8 +92,8 @@ describe('useMarketplaceCollectionsAndPlugins (integration)', () => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
expect(result.current.pluginCollections).toBeDefined()
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
})
it('should handle query with empty params (truthy)', async () => {

View File

@@ -118,10 +118,10 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
expect(result.current.isLoading).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
expect(typeof result.current.setMarketplaceCollections).toBe('function')
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
expect(result.current.marketplaceCollections).toBeUndefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
expect(typeof result.current.setPluginCollections).toBe('function')
expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
expect(result.current.pluginCollections).toBeUndefined()
expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
})
})
@@ -427,8 +427,8 @@ describe('Hooks queryFn Coverage', () => {
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
expect(result.current.pluginCollections).toBeDefined()
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
})
it('should test getNextPageParam via fetchNextPage behavior', async () => {

View File

@@ -2,6 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/headers', () => ({
headers: async () => ({
get: (name: string) => name === 'sec-fetch-dest' ? 'document' : null,
}),
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
@@ -15,15 +21,24 @@ vi.mock('@/utils/var', () => ({
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
plugins: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
plugins: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
},
},
}))
@@ -46,6 +61,9 @@ describe('HydrateQueryClient', () => {
mockCollectionPlugins.mockResolvedValue({
data: { plugins: [] },
})
mockSearchAdvanced.mockResolvedValue({
data: { plugins: [], total: 0 },
})
})
it('should render children within HydrationBoundary', async () => {
@@ -81,6 +99,7 @@ describe('HydrateQueryClient', () => {
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'all' }),
isMarketplacePlatform: true,
children: <div>Child</div>,
})
@@ -92,31 +111,36 @@ describe('HydrateQueryClient', () => {
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'tool' }),
isMarketplacePlatform: true,
children: <div>Child</div>,
})
expect(mockCollections).toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (model)', async () => {
it('should prefetch search results when category does not have collections (model)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'model' }),
isMarketplacePlatform: true,
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
expect(mockCollections).toHaveBeenCalled()
expect(mockSearchAdvanced).toHaveBeenCalled()
})
it('should not prefetch when category does not have collections (bundle)', async () => {
it('should prefetch search results when category does not have collections (bundle)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'bundle' }),
isMarketplacePlatform: true,
children: <div>Child</div>,
})
expect(mockCollections).not.toHaveBeenCalled()
expect(mockCollections).toHaveBeenCalled()
expect(mockSearchAdvanced).toHaveBeenCalled()
})
})

View File

@@ -8,24 +8,44 @@ vi.mock('@/context/query-client', () => ({
}))
vi.mock('../hydration-server', () => ({
HydrateQueryClient: ({ children }: { children: React.ReactNode }) => (
<div data-testid="hydration-client">{children}</div>
HydrateQueryClient: ({
children,
isMarketplacePlatform,
}: {
children: React.ReactNode
isMarketplacePlatform?: boolean
}) => (
<div data-testid="hydrate-query-client" data-marketplace-platform={String(Boolean(isMarketplacePlatform))}>{children}</div>
),
}))
vi.mock('../description', () => ({
default: () => <div data-testid="description">Description</div>,
}))
vi.mock('../list/list-wrapper', () => ({
default: ({ showInstallButton }: { showInstallButton: boolean }) => (
<div data-testid="list-wrapper" data-show-install={showInstallButton}>ListWrapper</div>
vi.mock('../hydration-client', () => ({
HydrateClient: ({
children,
isMarketplacePlatform,
}: {
children: React.ReactNode
isMarketplacePlatform?: boolean
}) => (
<div data-testid="hydrate-client" data-marketplace-platform={String(Boolean(isMarketplacePlatform))}>{children}</div>
),
}))
vi.mock('../sticky-search-and-switch-wrapper', () => ({
default: ({ pluginTypeSwitchClassName }: { pluginTypeSwitchClassName?: string }) => (
<div data-testid="sticky-wrapper" data-classname={pluginTypeSwitchClassName}>StickyWrapper</div>
vi.mock('../marketplace-header', () => ({
default: ({
marketplaceNav,
}: {
marketplaceNav?: React.ReactNode
}) => (
<div data-testid="marketplace-header">
{marketplaceNav}
</div>
),
}))
vi.mock('../marketplace-content', () => ({
default: ({ showInstallButton }: { showInstallButton?: boolean }) => (
<div data-testid="marketplace-content" data-show-install={String(Boolean(showInstallButton))}>MarketplaceContent</div>
),
}))
@@ -47,20 +67,20 @@ describe('Marketplace', () => {
const { getByTestId } = render(element as React.ReactElement)
expect(getByTestId('tanstack-initializer')).toBeInTheDocument()
expect(getByTestId('hydration-client')).toBeInTheDocument()
expect(getByTestId('description')).toBeInTheDocument()
expect(getByTestId('sticky-wrapper')).toBeInTheDocument()
expect(getByTestId('list-wrapper')).toBeInTheDocument()
expect(getByTestId('hydrate-query-client')).toBeInTheDocument()
expect(getByTestId('hydrate-client')).toBeInTheDocument()
expect(getByTestId('marketplace-header')).toBeInTheDocument()
expect(getByTestId('marketplace-content')).toBeInTheDocument()
})
it('should pass showInstallButton=true by default to ListWrapper', async () => {
it('should pass showInstallButton=true by default to MarketplaceContent', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('true')
const marketplaceContent = getByTestId('marketplace-content')
expect(marketplaceContent.getAttribute('data-show-install')).toBe('true')
})
it('should pass showInstallButton=false when specified', async () => {
@@ -69,27 +89,26 @@ describe('Marketplace', () => {
const { getByTestId } = render(element as React.ReactElement)
const listWrapper = getByTestId('list-wrapper')
expect(listWrapper.getAttribute('data-show-install')).toBe('false')
const marketplaceContent = getByTestId('marketplace-content')
expect(marketplaceContent.getAttribute('data-show-install')).toBe('false')
})
it('should pass pluginTypeSwitchClassName to StickySearchAndSwitchWrapper', async () => {
it('should pass marketplaceNav to MarketplaceHeader', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({ pluginTypeSwitchClassName: 'top-14' })
const element = await Marketplace({ marketplaceNav: <div data-testid="nav">Nav</div> })
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBe('top-14')
expect(getByTestId('nav')).toBeInTheDocument()
})
it('should render without pluginTypeSwitchClassName', async () => {
it('should pass isMarketplacePlatform to hydrate wrappers', async () => {
const Marketplace = (await import('../index')).default
const element = await Marketplace({})
const element = await Marketplace({ isMarketplacePlatform: true })
const { getByTestId } = render(element as React.ReactElement)
const stickyWrapper = getByTestId('sticky-wrapper')
expect(stickyWrapper.getAttribute('data-classname')).toBeNull()
expect(getByTestId('hydrate-query-client').getAttribute('data-marketplace-platform')).toBe('true')
expect(getByTestId('hydrate-client').getAttribute('data-marketplace-platform')).toBe('true')
})
})

View File

@@ -3,7 +3,23 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import PluginTypeSwitch from '../plugin-type-switch'
import { PluginCategorySwitch } from '../category-switch/plugin'
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
vi.mock('#i18n', () => ({
useTranslation: () => ({
@@ -35,14 +51,16 @@ const createWrapper = (searchParams = '') => {
return { Wrapper }
}
describe('PluginTypeSwitch', () => {
describe('PluginCategorySwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
})
it('should render all category options', () => {
const { Wrapper } = createWrapper()
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Models')).toBeInTheDocument()
@@ -56,7 +74,7 @@ describe('PluginTypeSwitch', () => {
it('should apply active styling to current category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
const allButton = screen.getByText('All').closest('div')
expect(allButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
@@ -64,7 +82,7 @@ describe('PluginTypeSwitch', () => {
it('should apply custom className', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch className="custom-class" />, { wrapper: Wrapper })
const { container } = render(<PluginCategorySwitch className="custom-class" />, { wrapper: Wrapper })
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('custom-class')
@@ -72,7 +90,7 @@ describe('PluginTypeSwitch', () => {
it('should update category when option is clicked', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
fireEvent.click(screen.getByText('Models'))
@@ -82,7 +100,7 @@ describe('PluginTypeSwitch', () => {
it('should handle clicking on category with collections (Tools)', () => {
const { Wrapper } = createWrapper('?category=model')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
fireEvent.click(screen.getByText('Tools'))
@@ -92,7 +110,7 @@ describe('PluginTypeSwitch', () => {
it('should handle clicking on category without collections (Models)', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
fireEvent.click(screen.getByText('Models'))
@@ -102,7 +120,7 @@ describe('PluginTypeSwitch', () => {
it('should handle clicking on bundles', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
fireEvent.click(screen.getByText('Bundles'))
@@ -112,7 +130,7 @@ describe('PluginTypeSwitch', () => {
it('should handle clicking on each category', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
render(<PluginCategorySwitch />, { wrapper: Wrapper })
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
categories.forEach((category) => {
@@ -125,7 +143,7 @@ describe('PluginTypeSwitch', () => {
it('should render icons for categories that have them', () => {
const { Wrapper } = createWrapper()
const { container } = render(<PluginTypeSwitch />, { wrapper: Wrapper })
const { container } = render(<PluginCategorySwitch />, { wrapper: Wrapper })
// "All" has no icon (icon: null), others should have SVG icons
const svgs = container.querySelectorAll('svg')

View File

@@ -20,16 +20,20 @@ const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
plugins: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
plugins: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
},
},
}))

View File

@@ -5,6 +5,10 @@ import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
}))
vi.mock('@/config', () => ({
API_PREFIX: '/api',
APP_VERSION: '1.0.0',
@@ -19,19 +23,38 @@ vi.mock('@/utils/var', () => ({
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
plugins: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
plugins: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
},
},
}))
@@ -55,9 +78,11 @@ const createWrapper = (searchParams = '') => {
return { Wrapper, queryClient }
}
describe('useMarketplaceData', () => {
describe('usePluginsMarketplaceData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
mockCollections.mockResolvedValue({
data: {
@@ -80,7 +105,7 @@ describe('useMarketplaceData', () => {
})
it('should return initial state with loading and collections data', async () => {
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all')
// Create a mock container for scroll
@@ -88,14 +113,14 @@ describe('useMarketplaceData', () => {
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.marketplaceCollections).toBeDefined()
expect(result.current.marketplaceCollectionPluginsMap).toBeDefined()
expect(result.current.pluginCollections).toBeDefined()
expect(result.current.pluginCollectionPluginsMap).toBeDefined()
expect(result.current.page).toBeDefined()
expect(result.current.isFetchingNextPage).toBe(false)
@@ -103,14 +128,14 @@ describe('useMarketplaceData', () => {
})
it('should return search mode data when search text is present', async () => {
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=all&q=test')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
@@ -123,7 +148,7 @@ describe('useMarketplaceData', () => {
})
it('should return plugins undefined in collection mode (not search mode)', async () => {
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
// "all" category with no search → collection mode
const { Wrapper } = createWrapper('?category=all')
@@ -131,7 +156,7 @@ describe('useMarketplaceData', () => {
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
@@ -144,14 +169,14 @@ describe('useMarketplaceData', () => {
})
it('should enable search for category without collections (e.g. model)', async () => {
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
@@ -177,7 +202,7 @@ describe('useMarketplaceData', () => {
},
})
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
// Use "model" to force search mode
const { Wrapper } = createWrapper('?category=model')
@@ -189,7 +214,7 @@ describe('useMarketplaceData', () => {
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
// Wait for data to fully load (isFetching becomes false, plugins become available)
await waitFor(() => {
@@ -206,7 +231,7 @@ describe('useMarketplaceData', () => {
})
it('should handle tags filter in search mode', async () => {
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
// tags in URL triggers search mode
const { Wrapper } = createWrapper('?category=all&tags=search')
@@ -214,7 +239,7 @@ describe('useMarketplaceData', () => {
container.id = 'marketplace-container'
document.body.appendChild(container)
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
@@ -238,7 +263,7 @@ describe('useMarketplaceData', () => {
},
})
const { useMarketplaceData } = await import('../state')
const { usePluginsMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?category=model')
const container = document.createElement('div')
@@ -249,7 +274,7 @@ describe('useMarketplaceData', () => {
Object.defineProperty(container, 'scrollHeight', { value: 1000, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 200, writable: true, configurable: true })
const { result } = renderHook(() => useMarketplaceData(), { wrapper: Wrapper })
const { result } = renderHook(() => usePluginsMarketplaceData(), { wrapper: Wrapper })
await waitFor(() => {
expect(result.current.plugins).toBeDefined()

View File

@@ -1,87 +0,0 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components to isolate wrapper logic
vi.mock('../plugin-type-switch', () => ({
default: () => <div data-testid="plugin-type-switch">PluginTypeSwitch</div>,
}))
vi.mock('../search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
const createWrapper = () => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider>
<NuqsWrapper>
{children}
</NuqsWrapper>
</JotaiProvider>
)
return { Wrapper }
}
describe('StickySearchAndSwitchWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
const { Wrapper } = createWrapper()
const { getByTestId } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
expect(getByTestId('search-box-wrapper')).toBeInTheDocument()
expect(getByTestId('plugin-type-switch')).toBeInTheDocument()
})
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('mt-4')
expect(outerDiv.className).not.toContain('sticky')
})
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).toContain('sticky')
expect(outerDiv.className).toContain('z-10')
expect(outerDiv.className).toContain('top-10')
})
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
const { Wrapper } = createWrapper()
const { container } = render(
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
{ wrapper: Wrapper },
)
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv.className).not.toContain('sticky')
expect(outerDiv.className).toContain('custom-class')
})
})

View File

@@ -23,9 +23,11 @@ const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
plugins: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
},
}))
@@ -107,7 +109,7 @@ describe('getPluginLinkInMarketplace', () => {
const { getPluginLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginLinkInMarketplace(plugin)
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
expect(link).toBe('https://marketplace.dify.ai/plugin/test-org/test-plugin')
})
it('should return correct link for bundle', async () => {
@@ -123,7 +125,7 @@ describe('getPluginDetailLinkInMarketplace', () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginDetailLinkInMarketplace(plugin)
expect(link).toBe('/plugins/test-org/test-plugin')
expect(link).toBe('/plugin/test-org/test-plugin')
})
it('should return correct detail link for bundle', async () => {
@@ -134,69 +136,69 @@ describe('getPluginDetailLinkInMarketplace', () => {
})
})
describe('getMarketplaceListCondition', () => {
describe('getPluginCondition', () => {
it('should return category condition for tool', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
})
it('should return category condition for model', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
})
it('should return category condition for agent', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
})
it('should return category condition for datasource', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
})
it('should return category condition for trigger', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
})
it('should return endpoint category for extension', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
})
it('should return type condition for bundle', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition('bundle')).toBe('type=bundle')
})
it('should return empty string for all', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition('all')).toBe('')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition('all')).toBe('')
})
it('should return empty string for unknown type', async () => {
const { getMarketplaceListCondition } = await import('../utils')
expect(getMarketplaceListCondition('unknown')).toBe('')
const { getPluginCondition } = await import('../utils')
expect(getPluginCondition('unknown')).toBe('')
})
})
describe('getMarketplaceListFilterType', () => {
describe('getPluginFilterType', () => {
it('should return undefined for all', async () => {
const { getMarketplaceListFilterType } = await import('../utils')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
const { getPluginFilterType } = await import('../utils')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
})
it('should return bundle for bundle', async () => {
const { getMarketplaceListFilterType } = await import('../utils')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
const { getPluginFilterType } = await import('../utils')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
})
it('should return plugin for other categories', async () => {
const { getMarketplaceListFilterType } = await import('../utils')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
const { getPluginFilterType } = await import('../utils')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
})
})

View File

@@ -1,31 +1,149 @@
import type { SearchTab } from './search-params'
import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params'
import { useCallback, useMemo } from 'react'
import { CATEGORY_ALL, DEFAULT_PLUGIN_SORT, DEFAULT_TEMPLATE_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
export function useMarketplaceSort() {
return useAtom(marketplaceSortAtom)
export const isMarketplacePlatformAtom = atom<boolean>(false)
const marketplacePluginSortAtom = atom<PluginsSort>(DEFAULT_PLUGIN_SORT)
export function useMarketplacePluginSort() {
return useAtom(marketplacePluginSortAtom)
}
export function useMarketplaceSortValue() {
return useAtomValue(marketplaceSortAtom)
export function useMarketplacePluginSortValue() {
return useAtomValue(marketplacePluginSortAtom)
}
export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom)
export function useSetMarketplacePluginSort() {
return useSetAtom(marketplacePluginSortAtom)
}
export function useSearchPluginText() {
const marketplaceTemplateSortAtom = atom<PluginsSort>(DEFAULT_TEMPLATE_SORT)
export function useMarketplaceTemplateSort() {
return useAtom(marketplaceTemplateSortAtom)
}
export function useMarketplaceTemplateSortValue() {
return useAtomValue(marketplaceTemplateSortAtom)
}
export function useSetMarketplaceTemplateSort() {
return useSetAtom(marketplaceTemplateSortAtom)
}
export function useSearchText() {
return useQueryState('q', marketplaceSearchParamsParsers.q)
}
export function useActivePluginType() {
return useQueryState('category', marketplaceSearchParamsParsers.category)
export function useActivePluginCategory() {
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
const router = useRouter()
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
const categoryFromPath = segments[1] || CATEGORY_ALL
const validatedCategory = getValidatedPluginCategory(categoryFromPath)
const handleChange = useCallback(
(newCategory: string) => {
router.push(`/plugins/${newCategory}`)
},
[router],
)
if (isAtMarketplace) {
return [validatedCategory, handleChange] as const
}
return [getValidatedPluginCategory(category), setCategory] as const
}
export function useActiveTemplateCategory() {
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
const router = useRouter()
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
const categoryFromPath = segments[1] || CATEGORY_ALL
const validatedCategory = getValidatedTemplateCategory(categoryFromPath)
const handleChange = useCallback(
(newCategory: string) => {
router.push(`/${CREATION_TYPE.templates}/${newCategory}`)
},
[router],
)
if (isAtMarketplace) {
return [validatedCategory, handleChange] as const
}
return [getValidatedTemplateCategory(category), setCategory] as const
}
export function useFilterPluginTags() {
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
}
export function useFilterTemplateLanguages() {
return useQueryState('languages', marketplaceSearchParamsParsers.languages)
}
export function useSearchTab() {
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
const state = useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
const router = useRouter()
// /search/[searchTab]
const { searchTab } = useParams()
const handleChange = useCallback(
(newTab: string) => {
const location = new URL(window.location.href)
location.pathname = `/search/${newTab}`
router.push(location.href)
},
[router],
)
if (isAtMarketplace) {
return [searchTab, handleChange] as const
}
return state
}
export function useCreationType() {
const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom)
const [creationType] = useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
const pathname = usePathname()
const segments = pathname.split('/').filter(Boolean)
if (isAtMarketplace) {
if (segments[0] === CREATION_TYPE.templates || segments[0] === 'template')
return CREATION_TYPE.templates
return CREATION_TYPE.plugins
}
return creationType
}
// Search-page-specific filter hooks (separate from list-page category/tags)
export function useSearchFilterCategories() {
return useQueryState('searchCategories', marketplaceSearchParamsParsers.searchCategories)
}
export function useSearchFilterLanguages() {
return useQueryState('searchLanguages', marketplaceSearchParamsParsers.searchLanguages)
}
export function useSearchFilterType() {
const [type, setType] = useQueryState('searchType', marketplaceSearchParamsParsers.searchType)
return [getValidatedPluginCategory(type), setType] as const
}
export function useSearchFilterTags() {
return useQueryState('searchTags', marketplaceSearchParamsParsers.searchTags)
}
/**
* Not all categories have collections, so we need to
* force the search mode for those categories.
@@ -33,30 +151,74 @@ export function useFilterPluginTags() {
export const searchModeAtom = atom<true | null>(null)
export function useMarketplaceSearchMode() {
const [searchPluginText] = useSearchPluginText()
const creationType = useCreationType()
const [searchText] = useSearchText()
const [searchTab] = useSearchTab()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginType] = useActivePluginType()
const [filterTemplateLanguages] = useFilterTemplateLanguages()
const [activePluginCategory] = useActivePluginCategory()
const [activeTemplateCategory] = useActiveTemplateCategory()
const isPluginsView = creationType === CREATION_TYPE.plugins
const searchMode = useAtomValue(searchModeAtom)
const isSearchMode = !!searchPluginText
|| filterPluginTags.length > 0
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
const isSearchMode = searchTab || searchText
|| (isPluginsView && filterPluginTags.length > 0)
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|| (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL)
|| (!isPluginsView && filterTemplateLanguages.length > 0)
return isSearchMode
}
/**
* Returns the active sort state based on the current creationType.
* Plugins use `marketplacePluginSortAtom`, templates use `marketplaceTemplateSortAtom`.
*/
export function useActiveSort(): [PluginsSort, (sort: PluginsSort) => void] {
const creationType = useCreationType()
const [pluginSort, setPluginSort] = useAtom(marketplacePluginSortAtom)
const [templateSort, setTemplateSort] = useAtom(marketplaceTemplateSortAtom)
const isTemplates = creationType === CREATION_TYPE.templates
const sort = isTemplates ? templateSort : pluginSort
const setSort = useMemo(
() => isTemplates ? setTemplateSort : setPluginSort,
[isTemplates, setTemplateSort, setPluginSort],
)
return [sort, setSort]
}
export function useActiveSortValue(): PluginsSort {
const creationType = useCreationType()
const pluginSort = useAtomValue(marketplacePluginSortAtom)
const templateSort = useAtomValue(marketplaceTemplateSortAtom)
return creationType === CREATION_TYPE.templates ? templateSort : pluginSort
}
export function useMarketplaceMoreClick() {
const [,setQ] = useSearchPluginText()
const setSort = useSetAtom(marketplaceSortAtom)
const [, setQ] = useSearchText()
const [, setSearchTab] = useSearchTab()
const setPluginSort = useSetAtom(marketplacePluginSortAtom)
const setTemplateSort = useSetAtom(marketplaceTemplateSortAtom)
const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection) => {
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
if (!searchParams)
return
setQ(searchParams?.query || '')
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
if (searchTab === 'templates') {
setTemplateSort({
sortBy: searchParams?.sort_by || DEFAULT_TEMPLATE_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_TEMPLATE_SORT.sortOrder,
})
}
else {
setPluginSort({
sortBy: searchParams?.sort_by || DEFAULT_PLUGIN_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_PLUGIN_SORT.sortOrder,
})
}
setSearchMode(true)
}, [setQ, setSort, setSearchMode])
if (searchTab)
setSearchTab(searchTab)
}, [setQ, setSearchTab, setPluginSort, setTemplateSort, setSearchMode])
}

View File

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

View File

@@ -0,0 +1,64 @@
'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
}
export const CommonCategorySwitch = ({
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 (
<div className={cn(
'flex shrink-0 items-center space-x-2',
!isHeroVariant && 'justify-center bg-background-body py-3',
className,
)}
>
{
options.map(option => (
<div
key={option.value}
className={getItemClassName(activeValue === option.value)}
onClick={() => onChange(option.value)}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}

View File

@@ -0,0 +1,152 @@
'use client'
import { useTranslation } from '#i18n'
import { RiArrowDownSLine, RiCloseCircleFill, RiGlobalLine } from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import { LANGUAGE_OPTIONS } from '../search-page/constants'
type HeroLanguagesFilterProps = {
languages: string[]
onLanguagesChange: (languages: string[]) => void
}
const LANGUAGE_LABEL_MAP: Record<string, string> = LANGUAGE_OPTIONS.reduce((acc, option) => {
acc[option.value] = option.nativeLabel
return acc
}, {} as Record<string, string>)
const HeroLanguagesFilter = ({
languages,
onLanguagesChange,
}: HeroLanguagesFilterProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const selectedLanguagesLength = languages.length
const hasSelected = selectedLanguagesLength > 0
const filteredOptions = useMemo(() => {
if (!searchText)
return LANGUAGE_OPTIONS
const normalizedSearchText = searchText.toLowerCase()
return LANGUAGE_OPTIONS.filter(option =>
option.nativeLabel.toLowerCase().includes(normalizedSearchText)
|| option.label.toLowerCase().includes(normalizedSearchText),
)
}, [searchText])
const handleCheck = (value: string) => {
if (languages.includes(value))
onLanguagesChange(languages.filter(language => language !== value))
else
onLanguagesChange([...languages, value])
}
return (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
<div
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
!hasSelected && open && 'bg-state-base-hover',
!hasSelected && !open && 'hover:bg-state-base-hover',
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
)}
>
<RiGlobalLine
className={cn(
'size-4 shrink-0',
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
)}
/>
<div className="system-md-medium flex items-center gap-0.5">
{!hasSelected && (
<span>{t('marketplace.languages', { ns: 'plugin' })}</span>
)}
{hasSelected && (
<span className="text-saas-dify-blue-inverted">
{languages
.map(language => LANGUAGE_LABEL_MAP[language])
.filter(Boolean)
.slice(0, 2)
.join(', ')}
</span>
)}
{selectedLanguagesLength > 2 && (
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
+
{selectedLanguagesLength - 2}
</span>
</div>
)}
</div>
{hasSelected && (
<RiCloseCircleFill
className="size-4 shrink-0 text-saas-dify-blue-inverted"
onClick={(e) => {
e.stopPropagation()
onLanguagesChange([])
}}
/>
)}
{!hasSelected && (
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
{filteredOptions.map(option => (
<div
key={option.value}
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => handleCheck(option.value)}
>
<Checkbox
className="mr-1"
checked={languages.includes(option.value)}
/>
<div className="system-sm-medium px-1 text-text-secondary">
{option.nativeLabel}
</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(HeroLanguagesFilter)

View File

@@ -0,0 +1,94 @@
'use client'
import { useTranslation } from '#i18n'
import { useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useTags } from '@/app/components/plugins/hooks'
import HeroTagsTrigger from './hero-tags-trigger'
type HeroTagsFilterProps = {
tags: string[]
onTagsChange: (tags: string[]) => void
}
const HeroTagsFilter = ({
tags,
onTagsChange,
}: HeroTagsFilterProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const { tags: options, tagsMap } = useTags()
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
else
onTagsChange([...tags, id])
}
const selectedTagsLength = tags.length
return (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
<HeroTagsTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={t('searchTags', { ns: 'pluginTags' }) || ''}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
{
filteredOptions.map(option => (
<div
key={option.name}
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => handleCheck(option.name)}
>
<Checkbox
className="mr-1"
checked={tags.includes(option.name)}
/>
<div className="system-sm-medium px-1 text-text-secondary">
{option.label}
</div>
</div>
))
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default HeroTagsFilter

View File

@@ -0,0 +1,86 @@
'use client'
import type { Tag } from '../../hooks'
import { useTranslation } from '#i18n'
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type HeroTagsTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
onTagsChange: (tags: string[]) => void
}
const HeroTagsTrigger = ({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: HeroTagsTriggerProps) => {
const { t } = useTranslation()
const hasSelected = !!selectedTagsLength
return (
<div
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
!hasSelected && open && 'bg-state-base-hover',
!hasSelected && !open && 'hover:bg-state-base-hover',
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
)}
>
<RiPriceTag3Line className={cn(
'size-4 shrink-0',
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
)}
/>
<div className="system-md-medium flex items-center gap-0.5">
{
!hasSelected && (
<span>{t('allTags', { ns: 'pluginTags' })}</span>
)
}
{
hasSelected && (
<span className="text-saas-dify-blue-inverted">
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
</span>
)
}
{
selectedTagsLength > 2 && (
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
+
{selectedTagsLength - 2}
</span>
</div>
)
}
</div>
{
hasSelected && (
<RiCloseCircleFill
className="size-4 shrink-0 text-saas-dify-blue-inverted"
onClick={(e) => {
e.stopPropagation()
onTagsChange([])
}}
/>
)
}
{
!hasSelected && (
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
)
}
</div>
)
}
export default React.memo(HeroTagsTrigger)

View File

@@ -0,0 +1,4 @@
'use client'
export { PluginCategorySwitch } from './plugin'
export { TemplateCategorySwitch } from './template'

View File

@@ -0,0 +1,94 @@
'use client'
import type { ActivePluginType } from '../constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { RiArchive2Line } from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
import { usePluginCategoryText } from './category-text'
import { CommonCategorySwitch } from './common'
import HeroTagsFilter from './hero-tags-filter'
type PluginTypeSwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const categoryValues = [
PLUGIN_TYPE_SEARCH_MAP.all,
PLUGIN_TYPE_SEARCH_MAP.model,
PLUGIN_TYPE_SEARCH_MAP.tool,
PLUGIN_TYPE_SEARCH_MAP.datasource,
PLUGIN_TYPE_SEARCH_MAP.trigger,
PLUGIN_TYPE_SEARCH_MAP.agent,
PLUGIN_TYPE_SEARCH_MAP.extension,
PLUGIN_TYPE_SEARCH_MAP.bundle,
] as const
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
}
export const PluginCategorySwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
const setSearchMode = useSetAtom(searchModeAtom)
const getPluginCategoryText = usePluginCategoryText()
const isHeroVariant = variant === 'hero'
const options = categoryValues.map(value => ({
value,
text: getPluginCategoryText(value, isHeroVariant),
icon: getTypeIcon(value, isHeroVariant),
}))
const handleChange = (value: string) => {
handleActivePluginCategoryChange(value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
setSearchMode(null)
}
}
if (!isHeroVariant) {
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
)
}
return (
<div className="flex shrink-0 items-center gap-2">
<HeroTagsFilter
tags={filterPluginTags}
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
/>
<div className="text-text-primary-on-surface">
·
</div>
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
import { useActiveTemplateCategory, useFilterTemplateLanguages } from '../atoms'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
import { useTemplateCategoryText } from './category-text'
import { CommonCategorySwitch } from './common'
import HeroLanguagesFilter from './hero-languages-filter'
type TemplateCategorySwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const categoryValues = [
CATEGORY_ALL,
TEMPLATE_CATEGORY_MAP.marketing,
TEMPLATE_CATEGORY_MAP.sales,
TEMPLATE_CATEGORY_MAP.support,
TEMPLATE_CATEGORY_MAP.operations,
TEMPLATE_CATEGORY_MAP.it,
TEMPLATE_CATEGORY_MAP.knowledge,
TEMPLATE_CATEGORY_MAP.design,
] as const
export const TemplateCategorySwitch = ({
className,
variant = 'default',
}: TemplateCategorySwitchProps) => {
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
const [filterTemplateLanguages, setFilterTemplateLanguages] = useFilterTemplateLanguages()
const getTemplateCategoryText = useTemplateCategoryText()
const isHeroVariant = variant === 'hero'
const options = categoryValues.map(value => ({
value,
text: getTemplateCategoryText(value),
icon: value === CATEGORY_ALL && isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
}))
if (!isHeroVariant) {
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
)
}
return (
<div className="flex shrink-0 items-center gap-2">
<HeroLanguagesFilter
languages={filterTemplateLanguages}
onLanguagesChange={languages => setFilterTemplateLanguages(languages.length ? languages : null)}
/>
<div className="text-text-primary-on-surface">
·
</div>
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
</div>
)
}

View File

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

View File

@@ -1,14 +1,21 @@
import { PluginCategoryEnum } from '../types'
export const DEFAULT_SORT = {
export const DEFAULT_PLUGIN_SORT = {
sortBy: 'install_count',
sortOrder: 'DESC',
}
export const DEFAULT_TEMPLATE_SORT = {
sortBy: 'usage_count',
sortOrder: 'DESC',
}
export const SCROLL_BOTTOM_THRESHOLD = 100
export const CATEGORY_ALL = 'all'
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
[CATEGORY_ALL]: CATEGORY_ALL,
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
@@ -21,6 +28,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
type ValueOf<T> = T[keyof T]
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
const VALID_PLUGIN_CATEGORIES = new Set<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP))
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
[
@@ -28,3 +36,28 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
PLUGIN_TYPE_SEARCH_MAP.tool,
],
)
export const TEMPLATE_CATEGORY_MAP = {
[CATEGORY_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 {
if (VALID_PLUGIN_CATEGORIES.has(category as ActivePluginType))
return category as ActivePluginType
return CATEGORY_ALL
}
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]
}

View File

@@ -1,649 +1,101 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Description from '../index'
import { Description } from '../index'
// ================================
// Mock external dependencies
// ================================
// Track mock locale for testing
let mockDefaultLocale = 'en-US'
// Mock translations with realistic values
const pluginTranslations: Record<string, string> = {
'marketplace.empower': 'Empower your AI development',
'marketplace.discover': 'Discover',
'marketplace.difyMarketplace': 'Dify Marketplace',
'marketplace.and': 'and',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Data Sources',
'category.triggers': 'Triggers',
'category.agents': 'Agent Strategies',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
}
const commonTranslations: Record<string, string> = {
'operation.in': 'in',
}
// Mock i18n hooks
vi.mock('#i18n', () => ({
useLocale: vi.fn(() => mockDefaultLocale),
useTranslation: vi.fn((ns: string) => ({
useTranslation: () => ({
t: (key: string) => {
if (ns === 'plugin')
return pluginTranslations[key] || key
if (ns === 'common')
return commonTranslations[key] || key
return key
const translations: Record<string, string> = {
'marketplace.pluginsHeroTitle': 'Build with plugins',
'marketplace.pluginsHeroSubtitle': 'Discover and install marketplace plugins.',
'marketplace.templatesHeroTitle': 'Build with templates',
'marketplace.templatesHeroSubtitle': 'Explore reusable templates.',
}
return translations[key] || key
},
})),
}),
}))
// ================================
// Description Component Tests
// ================================
let mockCreationType = 'plugins'
vi.mock('../../atoms', () => ({
useCreationType: () => mockCreationType,
}))
vi.mock('../../search-params', () => ({
CREATION_TYPE: {
plugins: 'plugins',
templates: 'templates',
},
}))
vi.mock('../../category-switch', () => ({
PluginCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="plugin-category-switch">{variant}</div>,
TemplateCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="template-category-switch">{variant}</div>,
}))
vi.mock('motion/react', () => ({
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
},
useMotionValue: (value: number) => ({ set: vi.fn(), get: () => value }),
useSpring: (value: unknown) => value,
useTransform: (...args: unknown[]) => {
const values = args[0]
if (Array.isArray(values))
return 0
return values
},
}))
class ResizeObserverMock {
observe() {}
disconnect() {}
}
describe('Description', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDefaultLocale = 'en-US'
mockCreationType = 'plugins'
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Description />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render h1 heading with empower text', () => {
it('should render plugin hero content by default', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent('Empower your AI development')
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with plugins')
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Discover and install marketplace plugins.')
expect(screen.getByTestId('plugin-category-switch')).toHaveTextContent('hero')
})
it('should render h2 subheading', () => {
it('should render template hero content when creationType is templates', () => {
mockCreationType = 'templates'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeInTheDocument()
})
it('should apply correct CSS classes to h1', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('title-4xl-semi-bold')
expect(heading).toHaveClass('mb-2')
expect(heading).toHaveClass('text-center')
expect(heading).toHaveClass('text-text-primary')
})
it('should apply correct CSS classes to h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('body-md-regular')
expect(subheading).toHaveClass('text-center')
expect(subheading).toHaveClass('text-text-tertiary')
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with templates')
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Explore reusable templates.')
expect(screen.getByTestId('template-category-switch')).toHaveTextContent('hero')
})
})
// ================================
// Non-Chinese Locale Rendering Tests
// ================================
describe('Non-Chinese Locale Rendering', () => {
beforeEach(() => {
mockDefaultLocale = 'en-US'
describe('Props', () => {
it('should render marketplace nav content when provided', () => {
render(<Description marketplaceNav={<div data-testid="marketplace-nav">Nav</div>} />)
expect(screen.getByTestId('marketplace-nav')).toBeInTheDocument()
})
it('should render discover text for en-US locale', () => {
render(<Description />)
it('should apply custom className to the sticky wrapper', () => {
const { container } = render(<Description className="custom-hero-class" />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all category names', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render "and" conjunction text', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
it('should render "in" preposition at the end for non-Chinese locales', () => {
render(<Description />)
expect(screen.getByText('in')).toBeInTheDocument()
})
it('should render Dify Marketplace text at the end for non-Chinese locales', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render category spans with styled underline effect', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
expect(styledSpans.length).toBe(7)
})
it('should apply text-text-secondary class to category spans', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.text-text-secondary')
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
})
})
// ================================
// Chinese (zh-Hans) Locale Rendering Tests
// ================================
describe('Chinese (zh-Hans) Locale Rendering', () => {
beforeEach(() => {
mockDefaultLocale = 'zh-Hans'
})
it('should render "in" text at the beginning for zh-Hans locale', () => {
render(<Description />)
// In zh-Hans mode, "in" appears at the beginning
const inElements = screen.getAllByText('in')
expect(inElements.length).toBeGreaterThanOrEqual(1)
})
it('should render Dify Marketplace text for zh-Hans locale', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render discover text for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all categories for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render both zh-Hans specific elements and shared elements', () => {
render(<Description />)
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
// then the same category list with "and" -> Bundles
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
})
// ================================
// Locale Variations Tests
// ================================
describe('Locale Variations', () => {
it('should use en-US locale by default', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle ja-JP locale as non-Chinese', () => {
mockDefaultLocale = 'ja-JP'
render(<Description />)
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should handle ko-KR locale as non-Chinese', () => {
mockDefaultLocale = 'ko-KR'
render(<Description />)
// Should render in non-Chinese format
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle de-DE locale as non-Chinese', () => {
mockDefaultLocale = 'de-DE'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle fr-FR locale as non-Chinese', () => {
mockDefaultLocale = 'fr-FR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle pt-BR locale as non-Chinese', () => {
mockDefaultLocale = 'pt-BR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle es-ES locale as non-Chinese', () => {
mockDefaultLocale = 'es-ES'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
})
// ================================
// Conditional Rendering Tests
// ================================
describe('Conditional Rendering', () => {
it('should render zh-Hans specific content when locale is zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// zh-Hans has additional span with mr-1 before "in" text at the start
const mrSpan = container.querySelector('span.mr-1')
expect(mrSpan).toBeInTheDocument()
})
it('should render non-Chinese specific content when locale is not zh-Hans', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// Non-Chinese has "in" and "Dify Marketplace" at the end
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should not render zh-Hans intro content for non-Chinese locales', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// For en-US, the order should be Discover ... in Dify Marketplace
// The "in" text should only appear once at the end
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// "in" should appear after "Bundles" and before "Dify Marketplace"
const bundlesIndex = content.indexOf('Bundles')
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
expect(bundlesIndex).toBeLessThan(inIndex)
expect(inIndex).toBeLessThan(marketplaceIndex)
})
it('should render zh-Hans with proper word order', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
const discoverIndex = content.indexOf('Discover')
expect(inIndex).toBeLessThan(marketplaceIndex)
expect(marketplaceIndex).toBeLessThan(discoverIndex)
})
})
// ================================
// Category Styling Tests
// ================================
describe('Category Styling', () => {
it('should apply underline effect with after pseudo-element styling', () => {
const { container } = render(<Description />)
const categorySpan = container.querySelector('.after\\:absolute')
expect(categorySpan).toBeInTheDocument()
})
it('should apply correct after pseudo-element classes', () => {
const { container } = render(<Description />)
// Check for the specific after pseudo-element classes
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply full width to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:w-full')
expect(categorySpans.length).toBe(7)
})
it('should apply correct height to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:h-2')
expect(categorySpans.length).toBe(7)
})
it('should apply bg-text-text-selected to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
expect(categorySpans.length).toBe(7)
})
it('should have z-index 1 on category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply left margin to category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.ml-1')
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
})
it('should apply both left and right margin to specific spans', () => {
const { container } = render(<Description />)
// Extensions and Bundles spans have both ml-1 and mr-1
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
expect(extensionsBundlesSpans.length).toBe(2)
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should render fragment as root element', () => {
const { container } = render(<Description />)
// Fragment renders h1 and h2 as direct children
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
})
it('should handle zh-Hant as non-Chinese simplified', () => {
mockDefaultLocale = 'zh-Hant'
render(<Description />)
// zh-Hant is different from zh-Hans, should use non-Chinese format
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
const discoverIndex = content.indexOf('Discover')
const marketplaceIndex = content.indexOf('Dify Marketplace')
// For non-Chinese locales, Discover should come before Dify Marketplace
expect(discoverIndex).toBeLessThan(marketplaceIndex)
})
})
// ================================
// Content Structure Tests
// ================================
describe('Content Structure', () => {
it('should have comma separators between categories', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// Commas should exist between categories
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
})
it('should have "and" before last category (Bundles)', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// "and" should appear before Bundles
const andIndex = content.indexOf('and')
const bundlesIndex = content.indexOf('Bundles')
expect(andIndex).toBeLessThan(bundlesIndex)
})
it('should render all text elements in correct order for en-US', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
const expectedOrder = [
'Discover',
'Models',
'Tools',
'Data Sources',
'Triggers',
'Agent Strategies',
'Extensions',
'and',
'Bundles',
'in',
'Dify Marketplace',
]
let lastIndex = -1
for (const text of expectedOrder) {
const currentIndex = content.indexOf(text)
expect(currentIndex).toBeGreaterThan(lastIndex)
lastIndex = currentIndex
}
})
it('should render all text elements in correct order for zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
const discoverIndex = content.indexOf('Discover')
const modelsIndex = content.indexOf('Models')
expect(inIndex).toBeLessThan(marketplaceIndex)
expect(marketplaceIndex).toBeLessThan(discoverIndex)
expect(discoverIndex).toBeLessThan(modelsIndex)
})
})
// ================================
// Layout Tests
// ================================
describe('Layout', () => {
it('should have shrink-0 on h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('shrink-0')
})
it('should have shrink-0 on h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('shrink-0')
})
it('should have flex layout on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('flex')
})
it('should have items-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('items-center')
})
it('should have justify-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('justify-center')
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
const h2 = screen.getByRole('heading', { level: 2 })
expect(h1).toBeInTheDocument()
expect(h2).toBeInTheDocument()
})
it('should have readable text content', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1.textContent).not.toBe('')
})
it('should have visible h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeVisible()
})
it('should have visible h2 heading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeVisible()
expect(container.querySelector('.custom-hero-class')).toBeInTheDocument()
})
})
})
// ================================
// Integration Tests
// ================================
describe('Description Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDefaultLocale = 'en-US'
})
it('should render complete component structure', () => {
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
// All category spans
const categorySpans = container.querySelectorAll('.body-md-medium')
expect(categorySpans.length).toBe(7)
})
it('should render complete zh-Hans structure', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
// All category spans
const categorySpans = container.querySelectorAll('.body-md-medium')
expect(categorySpans.length).toBe(7)
})
it('should correctly differentiate between zh-Hans and en-US layouts', () => {
// Render en-US
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enContent = enContainer.querySelector('h2')?.textContent || ''
unmountEn()
// Render zh-Hans
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
// Both should have all categories
expect(enContent).toContain('Models')
expect(zhContent).toContain('Models')
// But order should differ
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
const enDiscoverIndex = enContent.indexOf('Discover')
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
const zhDiscoverIndex = zhContent.indexOf('Discover')
// en-US: Discover comes before Dify Marketplace
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
// zh-Hans: Dify Marketplace comes before Discover
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
})
it('should maintain consistent styling across locales', () => {
// Render en-US
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
unmountEn()
// Render zh-Hans
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
// Both should have same number of styled category spans
expect(enCategoryCount).toBe(zhCategoryCount)
expect(enCategoryCount).toBe(7)
})
})

View File

@@ -1,72 +1,236 @@
import { useLocale, useTranslation } from '#i18n'
'use client'
const Description = () => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
import type { MotionValue } from 'motion/react'
import { useTranslation } from '#i18n'
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
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 { useCreationType } from '../atoms'
import { PluginCategorySwitch, TemplateCategorySwitch } from '../category-switch/index'
import { CREATION_TYPE } from '../search-params'
const isZhHans = locale === 'zh-Hans'
return (
<>
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary">
{t('marketplace.empower')}
</h1>
<h2 className="body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary">
{
isZhHans && (
<>
<span className="mr-1">{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
{t('marketplace.discover')}
</>
)
}
{
!isZhHans && (
<>
{t('marketplace.discover')}
</>
)
}
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.models')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.tools')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.datasources')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.triggers')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.agents')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.extensions')}
</span>
{t('marketplace.and')}
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.bundles')}
</span>
{
!isZhHans && (
<>
<span className="mr-1">{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
</>
)
}
</h2>
</>
)
type DescriptionProps = {
className?: string
scrollContainerId?: string
marketplaceNav?: React.ReactNode
}
export default Description
// Constants for collapse animation
const MAX_SCROLL = 120 // pixels to fully collapse
const EXPANDED_PADDING_TOP = 32 // pt-8
const COLLAPSED_PADDING_TOP = 12 // pt-3
const EXPANDED_PADDING_BOTTOM = 24 // pb-6
const COLLAPSED_PADDING_BOTTOM = 12 // pb-3
export const Description = ({
className,
scrollContainerId = 'marketplace-container',
marketplaceNav,
}: DescriptionProps) => {
const { t } = useTranslation('plugin')
const creationType = useCreationType()
const isTemplatesView = creationType === CREATION_TYPE.templates
const heroTitleKey = isTemplatesView ? 'marketplace.templatesHeroTitle' : 'marketplace.pluginsHeroTitle'
const heroSubtitleKey = isTemplatesView ? 'marketplace.templatesHeroSubtitle' : 'marketplace.pluginsHeroSubtitle'
const rafRef = useRef<number | null>(null)
const lastProgressRef = useRef(0)
const headerRef = useRef<HTMLDivElement | null>(null)
const titleContentRef = useRef<HTMLDivElement | null>(null)
const progress = useMotionValue(0)
const titleHeight = useMotionValue(72)
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
useLayoutEffect(() => {
const node = titleContentRef.current
if (!node)
return
const updateHeight = () => {
titleHeight.set(node.scrollHeight)
}
updateHeight()
if (typeof ResizeObserver === 'undefined')
return
const observer = new ResizeObserver(updateHeight)
observer.observe(node)
return () => observer.disconnect()
}, [titleHeight])
useEffect(() => {
const container = document.getElementById(scrollContainerId)
if (!container)
return
const handleScroll = () => {
// Cancel any pending animation frame
if (rafRef.current)
cancelAnimationFrame(rafRef.current)
// Use requestAnimationFrame for smooth updates
rafRef.current = requestAnimationFrame(() => {
const scrollTop = Math.round(container.scrollTop)
const heightDelta = container.scrollHeight - container.clientHeight
const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta))
const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1)
const snappedProgress = rawProgress >= 0.95
? 1
: rawProgress <= 0.05
? 0
: Math.round(rawProgress * 100) / 100
if (snappedProgress !== lastProgressRef.current) {
lastProgressRef.current = snappedProgress
progress.set(snappedProgress)
}
})
}
container.addEventListener('scroll', handleScroll, { passive: true })
// Initial check
handleScroll()
return () => {
container.removeEventListener('scroll', handleScroll)
if (rafRef.current)
cancelAnimationFrame(rafRef.current)
}
}, [progress, scrollContainerId])
// Calculate interpolated values
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
const titleMaxHeight: MotionValue<number> = useTransform(
[smoothProgress, titleHeight],
(values: number[]) => values[1] * (1 - values[0]),
)
const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, marketplaceNav ? 16 : 0])
const titleMarginTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? 80 : 0, 0])
const paddingTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP])
const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM])
useEffect(() => {
const container = document.getElementById(scrollContainerId)
const header = headerRef.current
if (!container || !header)
return
let maxHeaderHeight = 0
let lastAppliedOffset = 0
const updateOffset = () => {
const currentHeaderHeight = Math.round(header.getBoundingClientRect().height)
maxHeaderHeight = Math.max(maxHeaderHeight, currentHeaderHeight)
const collapsedHeight = Math.max(0, maxHeaderHeight - currentHeaderHeight)
const currentScrollableTop = container.scrollHeight - container.clientHeight
const baseScrollableTop = Math.max(0, currentScrollableTop - lastAppliedOffset)
const shouldCompensate = baseScrollableTop <= maxHeaderHeight
const nextOffset = shouldCompensate ? collapsedHeight : 0
const offsetDelta = nextOffset - lastAppliedOffset
if (nextOffset > 0) {
// Only compensate when content is short enough that header collapse can clamp scrollTop.
container.style.setProperty('--marketplace-header-collapse-offset', `${nextOffset}px`)
if (offsetDelta !== 0 && container.scrollTop > 0)
container.scrollTop = Math.max(0, container.scrollTop + offsetDelta)
}
else {
container.style.removeProperty('--marketplace-header-collapse-offset')
}
lastAppliedOffset = nextOffset
}
updateOffset()
if (typeof ResizeObserver === 'undefined') {
return () => {
container.style.removeProperty('--marketplace-header-collapse-offset')
}
}
const observer = new ResizeObserver(updateOffset)
observer.observe(header)
observer.observe(container)
return () => {
observer.disconnect()
container.style.removeProperty('--marketplace-header-collapse-offset')
}
}, [scrollContainerId])
return (
<motion.div
ref={headerRef}
className={cn(
'sticky top-[60px] z-20 mx-4 mt-4 shrink-0 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border px-6',
className,
)}
style={{
paddingTop,
paddingBottom,
}}
>
{/* Blue base background */}
<div className="absolute inset-0 bg-[rgba(0,51,255,0.9)]" />
{/* Decorative image with blend mode - showing top 1/3 of the image */}
<div
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
style={{
backgroundImage: `url(${marketPlaceBg.src})`,
backgroundSize: '110% auto',
backgroundPosition: 'center top',
}}
/>
{/* Gradient & Noise overlay */}
<div
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${marketplaceGradientNoise.src})` }}
/>
{marketplaceNav}
{/* Content */}
<div className="relative z-10">
{/* Title and subtitle - fade out and scale down */}
<motion.div
style={{
opacity: contentOpacity,
scale: contentScale,
transformOrigin: 'left top',
maxHeight: titleMaxHeight,
overflow: 'hidden',
willChange: 'opacity, transform',
marginTop: titleMarginTop,
}}
>
<div ref={titleContentRef}>
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-text-primary-on-surface">
{t(heroTitleKey)}
</h1>
<h2 className="body-md-regular shrink-0 text-text-secondary-on-surface">
{t(heroSubtitleKey)}
</h2>
</div>
</motion.div>
{/* Category switch tabs - Plugin or Template based on creationType */}
<motion.div style={{ marginTop: tabsMarginTop }}>
{isTemplatesView
? (
<TemplateCategorySwitch variant="hero" />
)
: (
<PluginCategorySwitch variant="hero" />
)}
</motion.div>
</div>
</motion.div>
)
}

View File

@@ -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<CollectionsAndPluginsSearchParams>()
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
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,

View File

@@ -0,0 +1,17 @@
'use client'
import { useHydrateAtoms } from 'jotai/utils'
import { isMarketplacePlatformAtom } from './atoms'
export function HydrateClient({
isMarketplacePlatform = false,
children,
}: {
isMarketplacePlatform?: boolean
children: React.ReactNode
}) {
useHydrateAtoms([
[isMarketplacePlatformAtom, isMarketplacePlatform],
])
return <>{children}</>
}

View File

@@ -1,43 +1,270 @@
import type { SearchParams } from 'nuqs/server'
import type { MarketplaceSearchParams } from './search-params'
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams } from './types'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { headers } from 'next/headers'
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 { marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
import {
CATEGORY_ALL,
DEFAULT_PLUGIN_SORT,
DEFAULT_TEMPLATE_SORT,
getValidatedPluginCategory,
getValidatedTemplateCategory,
PLUGIN_CATEGORY_WITH_COLLECTIONS,
PLUGIN_TYPE_SEARCH_MAP,
} from './constants'
import { CREATION_TYPE, marketplaceSearchParamsParsers, SEARCH_TABS } from './search-params'
import {
getCollectionsParams,
getMarketplaceCollectionsAndPlugins,
getMarketplaceCreators,
getMarketplacePlugins,
getMarketplaceTemplateCollectionsAndTemplates,
getMarketplaceTemplates,
getPluginFilterType,
} from './utils'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
export type Awaitable<T> = T | PromiseLike<T>
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
if (!searchParams) {
const ZERO_WIDTH_SPACE = '\u200B'
const SEARCH_PREVIEW_SIZE = 8
const SEARCH_PAGE_SIZE = 40
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
function pickFirstParam(value: string | string[] | undefined) {
if (Array.isArray(value))
return value[0]
return value
}
function getNextPageParam(lastPage: { page: number, page_size: number, total: number }) {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
}
type RouteParams = { category?: string, creationType?: string, searchTab?: string } | undefined
async function shouldSkipServerPrefetch() {
const requestHeaders = await headers()
return requestHeaders.get('sec-fetch-dest') !== 'document'
}
async function getDehydratedState(
params?: Awaitable<RouteParams>,
searchParams?: Awaitable<SearchParams>,
) {
if (await shouldSkipServerPrefetch())
return
}
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params: MarketplaceSearchParams = await loadSearchParams(searchParams)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
return
const rawParams = params ? await params : undefined
const rawSearchParams = searchParams ? await searchParams : undefined
const parsedSearchParams = await loadSearchParams(Promise.resolve(rawSearchParams ?? {}))
const routeState = rawSearchParams as SearchParams & {
category?: string | string[]
creationType?: string | string[]
searchTab?: string | string[]
}
const creationTypeFromSearch = pickFirstParam(routeState?.creationType)
const categoryFromSearch = pickFirstParam(routeState?.category)
const searchTabFromSearch = pickFirstParam(routeState?.searchTab)
const creationType = rawParams?.creationType === CREATION_TYPE.templates || creationTypeFromSearch === CREATION_TYPE.templates
? CREATION_TYPE.templates
: CREATION_TYPE.plugins
const category = creationType === CREATION_TYPE.templates
? getValidatedTemplateCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
: getValidatedPluginCategory(rawParams?.category ?? categoryFromSearch ?? CATEGORY_ALL)
const searchTabRaw = rawParams?.searchTab ?? searchTabFromSearch ?? ''
const searchTab = SEARCH_TABS.includes(searchTabRaw as (typeof SEARCH_TABS)[number])
? searchTabRaw as (typeof SEARCH_TABS)[number]
: ''
const queryClient = getQueryClientServer()
const prefetches: Promise<void>[] = []
await queryClient.prefetchQuery({
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
})
if (searchTab) {
const searchText = parsedSearchParams.q
const query = searchText === ZERO_WIDTH_SPACE ? '' : searchText.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
if (!hasQuery)
return
const pageSize = searchTab === 'all' ? SEARCH_PREVIEW_SIZE : SEARCH_PAGE_SIZE
const searchFilterType = getValidatedPluginCategory(parsedSearchParams.searchType)
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
if (fetchPlugins) {
const pluginCategory = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
? searchFilterType
: undefined
const searchFilterTags = searchTab === 'plugins' && parsedSearchParams.searchTags.length > 0
? parsedSearchParams.searchTags
: undefined
const pluginsParams: PluginsSearchParams = {
query,
page_size: pageSize,
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
category: pluginCategory,
tags: searchFilterTags,
type: getPluginFilterType(pluginCategory || PLUGIN_TYPE_SEARCH_MAP.all),
}
prefetches.push(queryClient.prefetchInfiniteQuery({
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
input: {
body: pluginsParams,
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
getNextPageParam,
initialPageParam: 1,
}))
}
if (fetchTemplates) {
const templateCategories = searchTab === 'templates' && parsedSearchParams.searchCategories.length > 0
? parsedSearchParams.searchCategories
: undefined
const templateLanguages = searchTab === 'templates' && parsedSearchParams.searchLanguages.length > 0
? parsedSearchParams.searchLanguages
: undefined
const templatesParams: TemplateSearchParams = {
query,
page_size: pageSize,
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
categories: templateCategories,
languages: templateLanguages,
}
prefetches.push(queryClient.prefetchInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: templatesParams,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
getNextPageParam,
initialPageParam: 1,
}))
}
if (fetchCreators) {
const creatorsParams: CreatorSearchParams = {
query,
page_size: pageSize,
}
prefetches.push(queryClient.prefetchInfiniteQuery({
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
input: {
body: creatorsParams,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(creatorsParams, pageParam, signal),
getNextPageParam,
initialPageParam: 1,
}))
}
}
else if (creationType === CREATION_TYPE.templates) {
prefetches.push(queryClient.prefetchQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
}))
const isSearchMode = !!parsedSearchParams.q
|| category !== CATEGORY_ALL
|| parsedSearchParams.languages.length > 0
if (isSearchMode) {
const templatesParams: TemplateSearchParams = {
query: parsedSearchParams.q,
categories: category === CATEGORY_ALL ? undefined : [category],
sort_by: DEFAULT_TEMPLATE_SORT.sortBy,
sort_order: DEFAULT_TEMPLATE_SORT.sortOrder,
...(parsedSearchParams.languages.length > 0 ? { languages: parsedSearchParams.languages } : {}),
}
prefetches.push(queryClient.prefetchInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: templatesParams,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(templatesParams, pageParam, signal),
getNextPageParam,
initialPageParam: 1,
}))
}
}
else {
const pluginCategory = getValidatedPluginCategory(category)
const collectionsParams = getCollectionsParams(pluginCategory)
prefetches.push(queryClient.prefetchQuery({
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
}))
const isSearchMode = !!parsedSearchParams.q
|| parsedSearchParams.tags.length > 0
|| !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)
if (isSearchMode) {
const pluginsParams: PluginsSearchParams = {
query: parsedSearchParams.q,
category: pluginCategory === CATEGORY_ALL ? undefined : pluginCategory,
tags: parsedSearchParams.tags,
sort_by: DEFAULT_PLUGIN_SORT.sortBy,
sort_order: DEFAULT_PLUGIN_SORT.sortOrder,
type: getPluginFilterType(pluginCategory),
}
prefetches.push(queryClient.prefetchInfiniteQuery({
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
input: {
body: pluginsParams,
params: { kind: pluginsParams.type === 'bundle' ? 'bundles' : 'plugins' },
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(pluginsParams, pageParam, signal),
getNextPageParam,
initialPageParam: 1,
}))
}
}
if (!prefetches.length)
return
await Promise.all(prefetches)
return dehydrate(queryClient)
}
export async function HydrateQueryClient({
params,
searchParams,
isMarketplacePlatform = false,
children,
}: {
searchParams: Promise<SearchParams> | undefined
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
searchParams?: Awaitable<SearchParams>
isMarketplacePlatform?: boolean
children: React.ReactNode
}) {
const dehydratedState = await getDehydratedState(searchParams)
const dehydratedState = isMarketplacePlatform ? await getDehydratedState(params, searchParams) : null
return (
<HydrationBoundary state={dehydratedState}>
{children}

View File

@@ -1,34 +1,48 @@
import type { SearchParams } from 'nuqs'
import type { Awaitable } from './hydration-server'
import { TanstackQueryInitializer } from '@/context/query-client'
import Description from './description'
import { cn } from '@/utils/classnames'
import { HydrateClient } from './hydration-client'
import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
import MarketplaceContent from './marketplace-content'
import MarketplaceHeader from './marketplace-header'
type MarketplaceProps = {
showInstallButton?: boolean
pluginTypeSwitchClassName?: string
/**
* Pass the search params from the request to prefetch data on the server.
* Pass the search params & params from the request to prefetch data on the server.
*/
searchParams?: Promise<SearchParams>
params?: Awaitable<{ category?: string, creationType?: string, searchTab?: string } | undefined>
searchParams?: Awaitable<SearchParams>
/**
* Whether the marketplace is the platform marketplace.
*/
isMarketplacePlatform?: boolean
marketplaceNav?: React.ReactNode
}
const Marketplace = async ({
const Marketplace = ({
showInstallButton = true,
pluginTypeSwitchClassName,
params,
searchParams,
isMarketplacePlatform = false,
marketplaceNav,
}: MarketplaceProps) => {
return (
<TanstackQueryInitializer>
<HydrateQueryClient searchParams={searchParams}>
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<ListWrapper
showInstallButton={showInstallButton}
/>
<HydrateQueryClient
isMarketplacePlatform={isMarketplacePlatform}
searchParams={searchParams}
params={params}
>
<HydrateClient
isMarketplacePlatform={isMarketplacePlatform}
>
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
<MarketplaceContent
showInstallButton={showInstallButton}
/>
</HydrateClient>
</HydrateQueryClient>
</TanstackQueryInitializer>
)

View File

@@ -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'
@@ -20,6 +20,7 @@ vi.mock('#i18n', () => ({
'plugin.marketplace.viewMore': 'View More',
'plugin.marketplace.pluginsResult': `${options?.num || 0} plugins found`,
'plugin.marketplace.noPluginFound': 'No plugins found',
'plugin.marketplace.noTemplateFound': 'No template found',
'plugin.detailPanel.operation.install': 'Install',
'plugin.detailPanel.operation.detail': 'Detail',
}
@@ -34,21 +35,28 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
mockMarketplaceData: {
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
pluginCollections: undefined as PluginCollection[] | undefined,
pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
isLoading: false,
page: 1,
},
mockMoreClick: vi.fn(),
}
})
let mockSearchMode = false
vi.mock('../../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
isPluginsData: (data: Record<string, unknown>) => 'pluginCollections' in data,
}))
vi.mock('../../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick,
useMarketplaceSearchMode: () => mockSearchMode,
useCreationType: () => 'plugins',
useFilterPluginTags: () => [[]],
useActivePluginCategory: () => ['all'],
useActiveTemplateCategory: () => ['all'],
}))
vi.mock('@/context/i18n', () => ({
@@ -99,12 +107,17 @@ vi.mock('@/i18n-config/language', () => ({
getLanguage: (locale: string) => locale || 'en-US',
}))
vi.mock('../../utils', () => ({
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
`/plugins/${plugin.org}/${plugin.name}`,
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
`/plugins/${plugin.org}/${plugin.name}`,
}))
// Mock marketplace utils
vi.mock('../../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils')>()
return {
...actual,
getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record<string, string | undefined>) =>
`/plugin/${plugin.org}/${plugin.name}`,
getPluginDetailLinkInMarketplace: (plugin: Plugin) =>
`/plugin/${plugin.org}/${plugin.name}`,
}
})
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => (
@@ -116,10 +129,10 @@ vi.mock('@/app/components/plugins/card', () => ({
),
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
<div data-testid="card-more-info">
<span data-testid="download-count">{downloadCount}</span>
// Mock CardTags component
vi.mock('@/app/components/plugins/card/card-tags', () => ({
default: ({ tags }: { tags: string[] }) => (
<div data-testid="card-tags">
<span data-testid="tags">{tags.join(',')}</span>
</div>
),
@@ -139,10 +152,11 @@ vi.mock('../../sort-dropdown', () => ({
),
}))
// Mock Empty component
vi.mock('../../empty', () => ({
default: ({ className }: { className?: string }) => (
default: ({ className, text }: { className?: string, text?: string }) => (
<div data-testid="empty-component" className={className}>
No plugins found
{text || 'No plugins found'}
</div>
),
}))
@@ -188,7 +202,7 @@ const createMockPluginList = (count: number): Plugin[] =>
label: { 'en-US': `Plugin ${i}` },
}))
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
name: `collection-${Math.random().toString(36).substring(7)}`,
label: { 'en-US': 'Test Collection' },
description: { 'en-US': 'Test collection description' },
@@ -200,7 +214,7 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
...overrides,
})
const createMockCollectionList = (count: number): MarketplaceCollection[] =>
const createMockCollectionList = (count: number): PluginCollection[] =>
Array.from({ length: count }, (_, i) =>
createMockCollection({
name: `collection-${i}`,
@@ -213,8 +227,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] =>
// ================================
describe('List', () => {
const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
pluginCollections: [] as PluginCollection[],
pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
plugins: undefined,
showInstallButton: false,
cardContainerClassName: '',
@@ -225,6 +239,7 @@ describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchMode = false
})
// ================================
@@ -248,8 +263,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
/>,
)
@@ -294,8 +309,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
plugins={[]}
/>,
)
@@ -406,12 +421,12 @@ describe('List', () => {
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty marketplaceCollections', () => {
it('should handle empty pluginCollections', () => {
render(
<List
{...defaultProps}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
/>,
)
@@ -428,8 +443,8 @@ describe('List', () => {
render(
<List
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
pluginCollections={collections}
pluginCollectionPluginsMap={pluginsMap}
plugins={undefined}
/>,
)
@@ -476,12 +491,12 @@ describe('List', () => {
// ================================
describe('ListWithCollection', () => {
const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
variant: 'plugins' as const,
collections: [] as PluginCollection[],
collectionItemsMap: {} as Record<string, Plugin[]>,
showInstallButton: false,
cardContainerClassName: '',
cardRender: undefined,
onMoreClick: undefined,
}
beforeEach(() => {
@@ -508,8 +523,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -528,8 +543,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -548,8 +563,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -562,21 +577,21 @@ describe('ListWithCollection', () => {
// View More Button Tests
// ================================
describe('View More Button', () => {
it('should render View More button when collection is searchable', () => {
it('should render View More button when collection is searchable and exceeds display limit', () => {
const collections = [createMockCollection({
name: 'collection-0',
name: 'searchable-collection',
searchable: true,
search_params: { query: 'test' },
})]
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
'searchable-collection': createMockPluginList(5),
}
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -587,16 +602,38 @@ describe('ListWithCollection', () => {
const collections = [createMockCollection({
name: 'collection-0',
searchable: false,
search_params: undefined,
})]
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
'collection-0': createMockPluginList(5),
}
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
expect(screen.queryByText('View More')).not.toBeInTheDocument()
})
it('should not render View More button when items do not exceed display limit', () => {
const collections = [createMockCollection({
name: 'small-collection',
searchable: true,
search_params: { query: 'test' },
})]
const pluginsMap: Record<string, Plugin[]> = {
'small-collection': createMockPluginList(4),
}
render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -606,26 +643,106 @@ 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: 'clickable-collection',
searchable: true,
search_params: searchParams,
})]
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
'clickable-collection': createMockPluginList(5),
}
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
fireEvent.click(screen.getByText('View More'))
expect(mockMoreClick).toHaveBeenCalledTimes(1)
expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
expect(mockMoreClick).toHaveBeenCalledWith(searchParams, undefined)
})
})
// ================================
// Grid Display Limit Tests
// ================================
describe('Grid Display Limit', () => {
it('should render at most 4 cards for searchable collections', () => {
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(8),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
expect(cards.length).toBe(4)
})
it('should render all cards for non-searchable collections in carousel mode', () => {
const collections = [createMockCollection({
name: 'carousel-collection',
searchable: false,
})]
const pluginsMap: Record<string, Plugin[]> = {
'carousel-collection': createMockPluginList(8),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
expect(cards.length).toBe(8)
})
it('should render all cards when count is within the display limit', () => {
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(3),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
expect(cards.length).toBe(3)
})
it('should render exactly 4 cards when collection has exactly 4 items', () => {
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(4),
}
const { container } = render(
<ListWithCollection
{...defaultProps}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
const cards = container.querySelectorAll('[data-testid^="card-plugin-"]')
expect(cards.length).toBe(4)
})
})
@@ -649,8 +766,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
cardRender={customCardRender}
/>,
)
@@ -673,8 +790,8 @@ describe('ListWithCollection', () => {
const { container } = render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
cardContainerClassName="custom-container"
/>,
)
@@ -691,8 +808,8 @@ describe('ListWithCollection', () => {
const { container } = render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
showInstallButton={true}
/>,
)
@@ -710,8 +827,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
collections={[]}
collectionItemsMap={{}}
/>,
)
@@ -726,8 +843,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -744,8 +861,8 @@ describe('ListWithCollection', () => {
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -761,11 +878,12 @@ describe('ListWithCollection', () => {
describe('ListWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchMode = false
// 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
})
@@ -804,22 +922,52 @@ describe('ListWrapper', () => {
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
it('should render template empty state with flex content wrapper when templates are empty', () => {
mockSearchMode = true
delete (mockMarketplaceData as Record<string, unknown>).pluginCollections
delete (mockMarketplaceData as Record<string, unknown>).pluginCollectionPluginsMap
;(mockMarketplaceData as Record<string, unknown>).templateCollections = []
;(mockMarketplaceData as Record<string, unknown>).templateCollectionTemplatesMap = {}
;(mockMarketplaceData as Record<string, unknown>).templates = []
const { container } = render(<ListWrapper />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('No template found')).toBeInTheDocument()
expect(container.querySelector('.relative.flex.grow.flex-col')).toBeInTheDocument()
})
it('should keep plugin empty text when plugins are empty', () => {
mockSearchMode = true
mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.pluginCollections = []
mockMarketplaceData.pluginCollectionPluginsMap = {}
render(<ListWrapper />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('No plugins found')).toBeInTheDocument()
})
})
// ================================
// Plugins Header Tests
// ================================
describe('Plugins Header', () => {
it('should render plugins result count when plugins are present', () => {
it('should render list top info when search mode is enabled', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(5)
mockMarketplaceData.pluginsTotal = 5
render(<ListWrapper />)
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
})
it('should render SortDropdown when plugins are present', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(1)
render(<ListWrapper />)
@@ -842,8 +990,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),
}
@@ -855,8 +1003,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),
}
@@ -880,13 +1028,13 @@ describe('ListWrapper', () => {
})
it('should show View More button and call moreClick hook', () => {
mockMarketplaceData.marketplaceCollections = [createMockCollection({
name: 'collection-0',
mockMarketplaceData.pluginCollections = [createMockCollection({
name: 'wrapper-collection',
searchable: true,
search_params: { query: 'test' },
})]
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
mockMarketplaceData.pluginCollectionPluginsMap = {
'wrapper-collection': createMockPluginList(5),
}
render(<ListWrapper />)
@@ -902,25 +1050,28 @@ describe('ListWrapper', () => {
// ================================
describe('Edge Cases', () => {
it('should handle empty plugins array', () => {
mockSearchMode = true
mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper />)
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should handle large pluginsTotal', () => {
it('should handle many plugin results', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(10)
mockMarketplaceData.pluginsTotal = 10000
render(<ListWrapper />)
expect(screen.getByText('10000 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-9')).toBeInTheDocument()
})
it('should handle both loading and has plugins', () => {
mockSearchMode = true
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.plugins = createMockPluginList(5)
@@ -928,9 +1079,7 @@ describe('ListWrapper', () => {
render(<ListWrapper />)
// Should show plugins header and list
expect(screen.getByText('50 plugins found')).toBeInTheDocument()
// Should not show loading because page > 1
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
})
@@ -954,8 +1103,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -963,7 +1112,7 @@ describe('CardWrapper (via List integration)', () => {
expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument()
})
it('should render CardMoreInfo with download count and tags', () => {
it('should render CardTags with tags', () => {
const plugin = createMockPlugin({
name: 'test-plugin',
install_count: 5000,
@@ -972,14 +1121,13 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
expect(screen.getByTestId('card-more-info')).toBeInTheDocument()
expect(screen.getByTestId('download-count')).toHaveTextContent('5000')
expect(screen.getByTestId('card-tags')).toBeInTheDocument()
})
})
@@ -992,8 +1140,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
@@ -1012,8 +1160,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1032,8 +1180,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1053,15 +1201,15 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
)
const detailLink = screen.getByText('Detail').closest('a')
expect(detailLink).toHaveAttribute('href', '/plugins/test-org/link-test-plugin')
expect(detailLink).toHaveAttribute('href', '/plugin/test-org/link-test-plugin')
expect(detailLink).toHaveAttribute('target', '_blank')
})
@@ -1071,8 +1219,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1087,8 +1235,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1103,8 +1251,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
@@ -1129,8 +1277,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={false}
/>,
@@ -1149,8 +1297,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={false}
/>,
@@ -1164,8 +1312,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1187,8 +1335,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1204,8 +1352,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1221,8 +1369,8 @@ describe('CardWrapper (via List integration)', () => {
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={[plugin]}
/>,
)
@@ -1243,8 +1391,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 () => {
@@ -1257,8 +1405,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),
}
@@ -1269,8 +1417,9 @@ describe('Combined Workflows', () => {
})
it('should transition from collections to search results', async () => {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
mockSearchMode = true
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
@@ -1285,20 +1434,21 @@ describe('Combined Workflows', () => {
rerender(<ListWrapper />)
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
})
it('should handle empty search results', () => {
mockSearchMode = true
mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
})
it('should support pagination (page > 1)', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(40)
mockMarketplaceData.pluginsTotal = 80
mockMarketplaceData.isLoading = true
@@ -1306,9 +1456,7 @@ describe('Combined Workflows', () => {
render(<ListWrapper />)
// Should show existing results while loading more
expect(screen.getByText('80 plugins found')).toBeInTheDocument()
// Should not show loading spinner for pagination
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
})
@@ -1332,8 +1480,9 @@ describe('Accessibility', () => {
const { container } = render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -1344,17 +1493,19 @@ describe('Accessibility', () => {
it('should have clickable View More button', () => {
const collections = [createMockCollection({
name: 'collection-0',
name: 'accessible-collection',
searchable: true,
search_params: { query: 'test' },
})]
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
'accessible-collection': createMockPluginList(5),
}
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
@@ -1368,13 +1519,13 @@ describe('Accessibility', () => {
const { container } = render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
const grid = container.querySelector('.grid-cols-4')
const grid = container.querySelector('.grid')
expect(grid).toBeInTheDocument()
})
})
@@ -1393,8 +1544,8 @@ describe('Performance', () => {
const startTime = performance.now()
render(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
pluginCollections={[]}
pluginCollectionPluginsMap={{}}
plugins={plugins}
/>,
)
@@ -1414,8 +1565,9 @@ describe('Performance', () => {
const startTime = performance.now()
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
variant="plugins"
collections={collections}
collectionItemsMap={pluginsMap}
/>,
)
const endTime = performance.now()

View File

@@ -8,7 +8,7 @@ import * as React from 'react'
import { useMemo } from 'react'
import Button from '@/app/components/base/button'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import CardTags from '@/app/components/plugins/card/card-tags'
import { useTags } from '@/app/components/plugins/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
@@ -43,14 +43,13 @@ const CardWrapperComponent = ({
if (showInstallButton) {
return (
<div
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
>
<Card
key={plugin.name}
payload={plugin}
footer={(
<CardMoreInfo
downloadCount={plugin.install_count}
<CardTags
tags={tagLabels}
/>
)}
@@ -88,15 +87,15 @@ const CardWrapperComponent = ({
return (
<a
className="group relative inline-block cursor-pointer rounded-xl"
className="group relative block cursor-pointer rounded-xl"
href={getPluginDetailLinkInMarketplace(plugin)}
>
<Card
key={plugin.name}
payload={plugin}
disableOrgLink
footer={(
<CardMoreInfo
downloadCount={plugin.install_count}
<CardTags
tags={tagLabels}
/>
)}

View File

@@ -0,0 +1,128 @@
'use client'
import type { RemixiconComponentType } from '@remixicon/react'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useMemo } from 'react'
import { Carousel as BaseCarousel, useCarousel } from '@/app/components/base/carousel'
import { cn } from '@/utils/classnames'
type CarouselProps = {
children: React.ReactNode
className?: string
gap?: number
showNavigation?: boolean
showPagination?: boolean
autoPlay?: boolean
autoPlayInterval?: number
}
type NavButtonProps = {
direction: 'left' | 'right'
disabled: boolean
onClick: () => void
Icon: RemixiconComponentType
}
const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => (
<button
className={cn(
'flex items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all',
disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
onClick={onClick}
disabled={disabled}
aria-label={`Scroll ${direction}`}
>
<Icon className="h-4 w-4 text-components-button-secondary-text" />
</button>
)
type CarouselControlsProps = {
showPagination: boolean
}
const CarouselControls = ({ showPagination }: CarouselControlsProps) => {
const { api, selectedIndex, scrollPrev, scrollNext } = useCarousel()
const scrollSnaps = api?.scrollSnapList() ?? []
const totalPages = scrollSnaps.length
if (totalPages <= 1)
return null
return (
<div className="absolute -top-10 right-0 flex items-center gap-3">
{showPagination && (
<div className="flex items-center gap-1">
{scrollSnaps.map((snap, index) => (
<button
key={snap}
className={cn(
'h-[5px] w-[5px] rounded-full transition-all',
selectedIndex === index
? 'w-4 bg-components-button-primary-bg'
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
)}
onClick={() => api?.scrollTo(index)}
aria-label={`Go to page ${index + 1}`}
/>
))}
</div>
)}
<div className="flex items-center gap-1">
<NavButton
direction="left"
disabled={totalPages <= 1}
onClick={scrollPrev}
Icon={RiArrowLeftSLine}
/>
<NavButton
direction="right"
disabled={totalPages <= 1}
onClick={scrollNext}
Icon={RiArrowRightSLine}
/>
</div>
</div>
)
}
const Carousel = ({
children,
className,
gap = 12,
showNavigation = true,
showPagination = true,
autoPlay = false,
autoPlayInterval = 5000,
}: CarouselProps) => {
const plugins = useMemo(() => {
if (!autoPlay)
return []
return [
BaseCarousel.Plugin.Autoplay({
delay: autoPlayInterval,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
}, [autoPlay, autoPlayInterval])
return (
<BaseCarousel
opts={{ align: 'start', containScroll: 'trimSnaps', loop: true }}
plugins={plugins}
className={className}
overlay={showNavigation ? <CarouselControls showPagination={showPagination} /> : null}
>
<BaseCarousel.Content style={{ columnGap: `${gap}px` }}>
{children}
</BaseCarousel.Content>
</BaseCarousel>
)
}
export default Carousel

View File

@@ -0,0 +1,34 @@
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
export const GRID_DISPLAY_LIMIT = 4
export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0'
export const CAROUSEL_PAGE_GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
export const CAROUSEL_PAGE_SIZE = {
base: 2,
sm: 4,
lg: 6,
xl: 8,
} as const
export const CAROUSEL_BREAKPOINTS = {
sm: 640,
lg: 1024,
xl: 1280,
} as const
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
export const CAROUSEL_COLLECTION_NAMES = {
partners: 'partners',
featured: 'featured',
} as const
export type BaseCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
searchable?: boolean
search_params?: { query?: string, sort_by?: string, sort_order?: string }
}

View File

@@ -0,0 +1,219 @@
'use client'
import type { SearchTab } from '../search-params'
import type { SearchParamsFromCollection } from '../types'
import type { BaseCollection } from './collection-constants'
import type { Locale } from '@/i18n-config/language'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import Empty from '../empty'
import { buildCarouselPages, getItemKeyByField } from '../utils'
import Carousel from './carousel'
import {
CAROUSEL_BREAKPOINTS,
CAROUSEL_PAGE_CLASS,
CAROUSEL_PAGE_GRID_CLASS,
CAROUSEL_PAGE_SIZE,
GRID_CLASS,
GRID_DISPLAY_LIMIT,
} from './collection-constants'
const getViewportWidth = () => typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth
const getCarouselItemsPerPage = (viewportWidth: number) => {
if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl)
return CAROUSEL_PAGE_SIZE.xl
if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg)
return CAROUSEL_PAGE_SIZE.lg
if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm)
return CAROUSEL_PAGE_SIZE.sm
return CAROUSEL_PAGE_SIZE.base
}
type ViewMoreButtonProps = {
searchParams?: SearchParamsFromCollection
searchTab?: SearchTab
}
export function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
const { t } = useTranslation()
const onMoreClick = useMarketplaceMoreClick()
return (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
onClick={() => onMoreClick(searchParams, searchTab)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
type CollectionHeaderProps<TCollection extends BaseCollection> = {
collection: TCollection
itemsLength: number
locale: Locale
viewMore: React.ReactNode
}
export function CollectionHeader<TCollection extends BaseCollection>({
collection,
itemsLength,
locale,
viewMore,
}: CollectionHeaderProps<TCollection>) {
const showViewMore = collection.searchable
&& !!collection.search_params
&& itemsLength > GRID_DISPLAY_LIMIT
return (
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">
{collection.label[getLanguage(locale)]}
</div>
<div className="system-xs-regular text-text-tertiary">
{collection.description[getLanguage(locale)]}
</div>
</div>
{showViewMore && viewMore}
</div>
)
}
type CarouselCollectionProps<TItem> = {
items: TItem[]
getItemKey: (item: TItem) => string
renderCard: (item: TItem) => React.ReactNode
cardContainerClassName?: string
}
export function CarouselCollection<TItem>({
items,
getItemKey,
renderCard,
cardContainerClassName,
}: CarouselCollectionProps<TItem>) {
const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
useEffect(() => {
const handleResize = () => setViewportWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth])
const pages = useMemo(() => buildCarouselPages(items, itemsPerPage), [items, itemsPerPage])
const hasMultiplePages = pages.length > 1
return (
<Carousel
showNavigation={hasMultiplePages}
showPagination={hasMultiplePages}
autoPlay={hasMultiplePages}
autoPlayInterval={5000}
>
{pages.map((pageItems, idx) => (
<div
key={pageItems[0] ? getItemKey(pageItems[0]) : idx}
className={CAROUSEL_PAGE_CLASS}
style={{ scrollSnapAlign: 'start' }}
>
<div className={cn(CAROUSEL_PAGE_GRID_CLASS, cardContainerClassName)}>
{pageItems.map(item => (
<div key={getItemKey(item)}>{renderCard(item)}</div>
))}
</div>
</div>
))}
</Carousel>
)
}
type CollectionListProps<TItem, TCollection extends BaseCollection> = {
collections: TCollection[]
collectionItemsMap: Record<string, TItem[]>
/** Field name to use as item key (e.g. 'plugin_id', 'id'). */
itemKeyField: keyof TItem
renderCard: (item: TItem) => React.ReactNode
/** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */
viewMoreSearchTab?: SearchTab
gridClassName?: string
cardContainerClassName?: string
emptyClassName?: string
emptyText?: string
}
function CollectionList<TItem, TCollection extends BaseCollection>({
collections,
collectionItemsMap,
itemKeyField,
renderCard,
viewMoreSearchTab,
gridClassName = GRID_CLASS,
cardContainerClassName,
emptyClassName,
emptyText,
}: CollectionListProps<TItem, TCollection>) {
const locale = useLocale()
const collectionsWithItems = collections.filter((collection) => {
return collectionItemsMap[collection.name]?.length
})
if (collectionsWithItems.length === 0) {
return <Empty className={emptyClassName} text={emptyText} />
}
return (
<>
{
collectionsWithItems.map((collection) => {
const items = collectionItemsMap[collection.name]
return (
<div
key={collection.name}
className="py-3"
>
<CollectionHeader
collection={collection}
itemsLength={items.length}
locale={locale}
viewMore={<ViewMoreButton searchParams={collection.search_params} searchTab={viewMoreSearchTab} />}
/>
{!collection.searchable
? (
<CarouselCollection
items={items}
getItemKey={item => getItemKeyByField(item, itemKeyField)}
renderCard={renderCard}
cardContainerClassName={cardContainerClassName}
/>
)
: (
<div className={cn(gridClassName, cardContainerClassName)}>
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
<div key={getItemKeyByField(item, itemKeyField)}>
{renderCard(item)}
</div>
))}
</div>
)}
</div>
)
})
}
</>
)
}
export default CollectionList

View File

@@ -0,0 +1,61 @@
'use client'
import type { Template } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-constants'
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) => {
const { items, variant } = props
const { t } = useTranslation()
if (!items.length) {
if (variant === 'templates')
return <Empty text={t('marketplace.noTemplateFound', { ns: 'plugin' })} />
return <Empty />
}
if (variant === 'plugins') {
const { showInstallButton } = props
return (
<div className={GRID_CLASS}>
{items.map(plugin => (
<CardWrapper
key={`${plugin.org}/${plugin.name}`}
plugin={plugin}
showInstallButton={showInstallButton}
/>
))}
</div>
)
}
return (
<div className={GRID_CLASS}>
{items.map(template => (
<TemplateCard
key={template.id}
template={template}
/>
))}
</div>
)
}
export default FlatList

View File

@@ -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-constants'
import ListWithCollection from './list-with-collection'
type ListProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
pluginCollections: PluginCollection[]
pluginCollectionPluginsMap: Record<string, Plugin[]>
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 && (
<ListWithCollection
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
variant="plugins"
collections={pluginCollections}
collectionItemsMap={pluginCollectionPluginsMap}
showInstallButton={showInstallButton}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
@@ -39,11 +42,7 @@ const List = ({
}
{
plugins && !!plugins.length && (
<div className={cn(
'grid grid-cols-4 gap-3',
cardContainerClassName,
)}
>
<div className={cn(GRID_CLASS, cardContainerClassName)}>
{
plugins.map((plugin) => {
if (cardRender)

View File

@@ -0,0 +1,71 @@
'use client'
import { useTranslation } from '#i18n'
import {
useActivePluginCategory,
useActiveTemplateCategory,
useCreationType,
useFilterPluginTags,
} from '../atoms'
import { usePluginCategoryText, useTemplateCategoryText } from '../category-switch/category-text'
import {
CATEGORY_ALL,
} from '../constants'
import { CREATION_TYPE } from '../search-params'
import SortDropdown from '../sort-dropdown'
const ListTopInfo = () => {
const creationType = useCreationType()
const { t } = useTranslation()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginCategory] = useActivePluginCategory()
const [activeTemplateCategory] = useActiveTemplateCategory()
const getPluginCategoryText = usePluginCategoryText()
const getTemplateCategoryText = useTemplateCategoryText()
const isPluginsView = creationType === CREATION_TYPE.plugins
const hasTags = isPluginsView && filterPluginTags.length > 0
if (hasTags) {
return (
<div className="mb-4 flex items-center justify-between pt-3">
<p className="title-xl-semi-bold text-text-primary">
{t('marketplace.listTopInfo.tagsTitle', { ns: 'plugin' })}
</p>
<SortDropdown />
</div>
)
}
const isAllCategory = isPluginsView
? activePluginCategory === CATEGORY_ALL
: activeTemplateCategory === CATEGORY_ALL
const categoryText = isPluginsView
? getPluginCategoryText(activePluginCategory)
: getTemplateCategoryText(activeTemplateCategory)
const title = t(
`marketplace.listTopInfo.${creationType}${isAllCategory ? 'TitleAll' : 'TitleByCategory'}`,
isAllCategory
? { ns: 'plugin' }
: { ns: 'plugin', category: categoryText },
)
return (
<div className="mb-4 flex items-center justify-between pt-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="title-xl-semi-bold truncate text-text-primary">
{title}
</p>
<p className="system-xs-regular truncate text-text-tertiary">
{t(`marketplace.listTopInfo.${creationType}Subtitle`, { ns: 'plugin' })}
</p>
</div>
<SortDropdown />
</div>
)
}
export default ListTopInfo

View File

@@ -1,83 +1,83 @@
'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 { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import { useTranslation } from '#i18n'
import CardWrapper from './card-wrapper'
import CollectionList from './collection-list'
import TemplateCard from './template-card'
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
type BaseProps = {
cardContainerClassName?: string
}
type PluginsVariant = BaseProps & {
variant: 'plugins'
collections: PluginCollection[]
collectionItemsMap: Record<string, Plugin[]>
showInstallButton?: boolean
cardRender?: (plugin: Plugin) => React.JSX.Element | null
}
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
cardContainerClassName,
cardRender,
}: ListWithCollectionProps) => {
type TemplatesVariant = BaseProps & {
variant: 'templates'
collections: TemplateCollection[]
collectionItemsMap: Record<string, Template[]>
}
type ListWithCollectionProps = PluginsVariant | TemplatesVariant
const ListWithCollection = (props: ListWithCollectionProps) => {
const { variant, cardContainerClassName } = props
const { t } = useTranslation()
const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
if (variant === 'plugins') {
const {
collections,
collectionItemsMap,
showInstallButton,
cardRender,
} = props
const renderPluginCard = (plugin: Plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
}
return (
<CollectionList
collections={collections}
collectionItemsMap={collectionItemsMap}
itemKeyField="plugin_id"
renderCard={renderPluginCard}
cardContainerClassName={cardContainerClassName}
/>
)
}
const { collections, collectionItemsMap } = props
const renderTemplateCard = (template: Template) => (
<TemplateCard template={template} />
)
return (
<>
{
marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div
key={collection.name}
className="py-3"
>
<div className="flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{
collection.searchable && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
onClick={() => onMoreClick(collection.search_params)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
</div>
<div className={cn(
'mt-2 grid grid-cols-4 gap-3',
cardContainerClassName,
)}
>
{
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
})
}
</div>
</div>
))
}
</>
<CollectionList
collections={collections}
collectionItemsMap={collectionItemsMap}
itemKeyField="id"
renderCard={renderTemplateCard}
viewMoreSearchTab="templates"
cardContainerClassName={cardContainerClassName}
emptyText={t('marketplace.noTemplateFound', { ns: 'plugin' })}
/>
)
}

View File

@@ -0,0 +1,89 @@
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,
isPluginsData: (data: typeof mockMarketplaceData) => data.creationType === 'plugins',
}))
vi.mock('../atoms', () => ({
useMarketplaceSearchMode: () => false,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading-component">Loading</div>,
}))
vi.mock('./flat-list', () => ({
default: ({ variant, items }: { variant: 'plugins' | 'templates', items: unknown[] }) => (
<div data-testid={`flat-list-${variant}`}>
{items.length}
</div>
),
}))
vi.mock('./list-with-collection', () => ({
default: ({ variant }: { variant: 'plugins' | 'templates' }) => (
<div data-testid={`collection-list-${variant}`}>collection</div>
),
}))
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(<ListWrapper />)
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 = [{ id: 't1' } as Template]
render(<ListWrapper />)
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(<ListWrapper />)
expect(screen.getByTestId('collection-list-templates')).toBeInTheDocument()
expect(screen.queryByTestId('flat-list-templates')).not.toBeInTheDocument()
})
})

View File

@@ -1,64 +1,70 @@
'use client'
import { useTranslation } from '#i18n'
import Loading from '@/app/components/base/loading'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index'
import { useMarketplaceSearchMode } from '../atoms'
import { isPluginsData, useMarketplaceData } from '../state'
import FlatList from './flat-list'
import ListTopInfo from './list-top-info'
import ListWithCollection from './list-with-collection'
type ListWrapperProps = {
showInstallButton?: boolean
}
const ListWrapper = ({
showInstallButton,
}: ListWrapperProps) => {
const { t } = useTranslation()
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
isFetchingNextPage,
page,
} = useMarketplaceData()
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const marketplaceData = useMarketplaceData()
const { isLoading, page, isFetchingNextPage } = marketplaceData
const isSearchMode = useMarketplaceSearchMode()
const renderContent = () => {
if (isPluginsData(marketplaceData)) {
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
return plugins !== undefined
? (
<FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
)
: (
<ListWithCollection
variant="plugins"
collections={pluginCollections || []}
collectionItemsMap={pluginCollectionPluginsMap || {}}
showInstallButton={showInstallButton}
/>
)
}
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
return templates !== undefined
? (
<FlatList variant="templates" items={templates} />
)
: (
<ListWithCollection
variant="templates"
collections={templateCollections || []}
collectionItemsMap={templateCollectionTemplatesMap || {}}
/>
)
}
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
style={{
scrollbarGutter: 'stable',
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
}}
className="relative flex grow flex-col bg-background-default-subtle px-12 pt-2"
>
{
plugins && (
<div className="mb-4 flex items-center pt-3">
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
<SortDropdown />
</div>
)
}
{
isLoading && page === 1 && (
{isSearchMode && <ListTopInfo />}
<div className="relative flex grow flex-col">
{isLoading && page === 1 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)
}
{
(!isLoading || page > 1) && (
<List
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton={showInstallButton}
/>
)
}
{
isFetchingNextPage && (
<Loading className="my-3" />
)
}
)}
{(!isLoading || page > 1) && renderContent()}
</div>
{isFetchingNextPage && <Loading className="my-3" />}
</div>
)
}

View File

@@ -0,0 +1,259 @@
import type { Template } from '../types'
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TemplateCard from './template-card'
// Mock AppIcon component to capture props for assertion
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ size, iconType, icon, imageUrl, background }: {
size?: string
iconType?: string
icon?: string
imageUrl?: string | null
background?: string | null
}) => (
<span
data-testid="app-icon"
data-size={size}
data-icon-type={iconType}
data-icon={icon}
data-image-url={imageUrl || ''}
data-background={background || ''}
/>
),
}))
// Mock i18n
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'marketplace.templateCard.by')
return `by ${options?.author || ''}`
if (key === 'usedCount')
return `${options?.num || 0} used`
return key
},
}),
useLocale: () => 'en-US',
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode, href: string }) => (
<a href={href} {...props}>{children}</a>
),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
// Mock marketplace utils
vi.mock('@/utils/get-icon', () => ({
getIconFromMarketPlace: (id: string) => `https://marketplace.dify.ai/api/v1/plugins/${id}/icon`,
}))
vi.mock('@/utils/template', () => ({
formatUsedCount: (count: number) => String(count),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
}))
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
default: ({ text }: { text: string }) => <span data-testid="corner-mark">{text}</span>,
}))
// Mock marketplace utils (getTemplateIconUrl) while keeping other exports intact.
vi.mock('../utils', async () => {
const actual = await vi.importActual<typeof import('../utils')>('../utils')
return {
...actual,
getTemplateIconUrl: (template: { id: string, icon?: string, icon_file_key?: string }): string => {
if (template.icon?.startsWith('http'))
return template.icon
if (template.icon_file_key)
return `https://marketplace.dify.ai/api/v1/templates/${template.id}/icon`
return ''
},
}
})
// ================================
// Test Data Factories
// ================================
const createMockTemplate = (overrides?: Partial<Template>): Template => ({
id: 'test-template-id',
index_id: 'test-template-id',
template_name: 'test-template',
icon: '📄',
icon_background: '',
icon_file_key: '',
categories: ['Agent'],
overview: 'A test template',
readme: 'readme content',
partner_link: '',
deps_plugins: [],
preferred_languages: ['en'],
publisher_handle: 'test-publisher',
publisher_type: 'individual',
kind: 'classic',
status: 'published',
usage_count: 100,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
})
// ================================
// Tests
// ================================
describe('TemplateCard', () => {
describe('Icon Rendering via AppIcon', () => {
it('should pass emoji id to AppIcon when icon is an emoji id like sweat_smile', () => {
const template = createMockTemplate({ icon: 'sweat_smile' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon).toBeInTheDocument()
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
expect(appIcon?.getAttribute('data-icon')).toBe('sweat_smile')
expect(appIcon?.getAttribute('data-size')).toBe('large')
})
it('should pass unicode emoji to AppIcon when icon is a unicode character', () => {
const template = createMockTemplate({ icon: '😅' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon).toBeInTheDocument()
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
expect(appIcon?.getAttribute('data-icon')).toBe('😅')
})
it('should pass default fallback icon to AppIcon when icon and icon_file_key are both empty', () => {
const template = createMockTemplate({ icon: '', icon_file_key: '' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon).toBeInTheDocument()
expect(appIcon?.getAttribute('data-icon-type')).toBe('emoji')
expect(appIcon?.getAttribute('data-icon')).toBe('📄')
})
it('should pass image URL to AppIcon when icon is a URL', () => {
const template = createMockTemplate({ icon: 'https://example.com/icon.png' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon).toBeInTheDocument()
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
expect(appIcon?.getAttribute('data-image-url')).toBe('https://example.com/icon.png')
// icon prop should not be set for URL icons
expect(appIcon?.hasAttribute('data-icon')).toBe(false)
})
it('should resolve image URL from icon_file_key when icon is empty but icon_file_key is set', () => {
const template = createMockTemplate({
id: 'tpl-123',
icon: '',
icon_file_key: 'fa3b0f86-bc64-47ec-ad83-8e3cfc6739ae.jpg',
})
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon).toBeInTheDocument()
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
expect(appIcon?.getAttribute('data-image-url')).toBe('https://marketplace.dify.ai/api/v1/templates/tpl-123/icon')
// icon prop should not be set when rendering as image
expect(appIcon?.hasAttribute('data-icon')).toBe(false)
})
it('should prefer icon URL over icon_file_key when both are present', () => {
const template = createMockTemplate({
icon: 'https://example.com/custom-icon.png',
icon_file_key: 'fa3b0f86-bc64-47ec-ad83-8e3cfc6739ae.jpg',
})
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon?.getAttribute('data-icon-type')).toBe('image')
expect(appIcon?.getAttribute('data-image-url')).toBe('https://example.com/custom-icon.png')
})
})
describe('Avatar Background', () => {
it('should pass icon_background to AppIcon when provided', () => {
const template = createMockTemplate({ icon: 'sweat_smile', icon_background: '#FFEAD5' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
expect(appIcon?.getAttribute('data-background')).toBe('#FFEAD5')
})
it('should not pass background to AppIcon when icon_background is empty', () => {
const template = createMockTemplate({ icon: 'sweat_smile', icon_background: '' })
const { container } = render(<TemplateCard template={template} />)
const appIcon = container.querySelector('[data-testid="app-icon"]')
// Empty string means no background was passed (undefined becomes '')
expect(appIcon?.getAttribute('data-background')).toBe('')
})
})
describe('Sandbox', () => {
it('should render CornerMark when kind is sandboxed', () => {
const template = createMockTemplate({ kind: 'sandboxed' })
const { container } = render(<TemplateCard template={template} />)
const cornerMark = container.querySelector('[data-testid="corner-mark"]')
expect(cornerMark).toBeInTheDocument()
expect(cornerMark?.textContent).toBe('Sandbox')
})
it('should not render CornerMark when kind is classic', () => {
const template = createMockTemplate({ kind: 'classic' })
const { container } = render(<TemplateCard template={template} />)
const cornerMark = container.querySelector('[data-testid="corner-mark"]')
expect(cornerMark).not.toBeInTheDocument()
})
})
describe('Creator Link', () => {
it('should append publisher_type query to creator link', () => {
const template = createMockTemplate({ publisher_type: 'organization' })
const { getByText } = render(<TemplateCard template={template} />)
const creatorLink = getByText('test-publisher').closest('a')
expect(creatorLink).toHaveAttribute('href', '/creator/test-publisher?publisher_type=organization')
})
})
describe('Deps Plugins', () => {
it('should render dep plugin icons', () => {
const template = createMockTemplate({
deps_plugins: ['langgenius/google-search', 'langgenius/dalle'],
})
const { container } = render(<TemplateCard template={template} />)
const pluginIcons = container.querySelectorAll('.h-6.w-6 img')
expect(pluginIcons.length).toBe(2)
})
it('should show +N when deps_plugins exceed MAX_VISIBLE_DEPS_PLUGINS', () => {
const deps = Array.from({ length: 10 }, (_, i) => `org/plugin-${i}`)
const template = createMockTemplate({ deps_plugins: deps })
const { container } = render(<TemplateCard template={template} />)
// Should show 7 visible + "+3"
const pluginIcons = container.querySelectorAll('.h-6.w-6 img')
expect(pluginIcons.length).toBe(7)
expect(container.textContent).toContain('+3')
})
})
})

View File

@@ -0,0 +1,149 @@
'use client'
import type { Template } from '../types'
import { useLocale, useTranslation } from '#i18n'
import Link from 'next/link'
import * as React from 'react'
import { useMemo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import CornerMark from '@/app/components/plugins/card/base/corner-mark'
import { MARKETPLACE_URL_PREFIX } from '@/config'
import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import { formatUsedCount } from '@/utils/template'
import { getMarketplaceUrl } from '@/utils/var'
import { buildSearchParamsString, getTemplateIconUrl } from '../utils'
type TemplateCardProps = {
template: Template
className?: string
includeSource?: boolean
}
// Number of tag icons to show before showing "+X"
const MAX_VISIBLE_DEPS_PLUGINS = 7
const TemplateCardComponent = ({
template,
className,
includeSource = false,
}: TemplateCardProps) => {
const locale = useLocale()
const { t } = useTranslation()
const { theme } = useTheme()
const { id, template_name, overview, icon, publisher_handle, publisher_type, usage_count, icon_background, deps_plugins, kind } = template
const isSandbox = kind === 'sandboxed'
const iconUrl = getTemplateIconUrl(template)
const href = useMemo(() => {
const queryParams = {
theme,
language: locale,
templateId: id,
creationType: 'templates',
}
const encodedPublisherHandle = encodeURIComponent(publisher_handle)
const encodedTemplateName = encodeURIComponent(template_name)
return includeSource
? getMarketplaceUrl(`/template/${encodedPublisherHandle}/${encodedTemplateName}`, queryParams)
: `${MARKETPLACE_URL_PREFIX}/template/${encodedPublisherHandle}/${encodedTemplateName}?${buildSearchParamsString(queryParams)}`
}, [publisher_handle, template_name, theme, locale, id, includeSource])
const visibleDepsPlugins = deps_plugins?.slice(0, MAX_VISIBLE_DEPS_PLUGINS) || []
const remainingDepsPluginsCount = deps_plugins ? Math.max(0, deps_plugins.length - MAX_VISIBLE_DEPS_PLUGINS) : 0
const formattedUsedCount = formatUsedCount(usage_count, { precision: 0, rounding: 'floor' })
return (
<div
className={cn(
'hover-bg-components-panel-on-panel-item-bg relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs',
className,
)}
>
{isSandbox && <CornerMark text="Sandbox" />}
{/* Header */}
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
{/* Avatar */}
<AppIcon
size="large"
iconType={iconUrl ? 'image' : 'emoji'}
icon={iconUrl ? undefined : (icon || '📄')}
imageUrl={iconUrl || undefined}
background={icon_background || undefined}
/>
{/* Title */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<a
href={href}
className="system-md-medium truncate text-text-primary after:absolute after:inset-0"
>
{template_name}
</a>
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
<span className="relative z-[1] flex shrink-0 items-center gap-1">
<span className="shrink-0">{t('marketplace.templateCard.by', { ns: 'plugin' })}</span>
<Link
href={`/creator/${publisher_handle}?publisher_type=${encodeURIComponent(publisher_type || 'individual')}`}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:text-text-secondary hover:underline"
>
{publisher_handle}
</Link>
</span>
<span className="shrink-0">·</span>
<span className="shrink-0">
{t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })}
</span>
</div>
</div>
</div>
{/* Description */}
<div className="shrink-0 px-4 pb-2 pt-1">
<p
className="system-xs-regular line-clamp-2 min-h-[32px] text-text-secondary"
title={overview}
>
{overview}
</p>
</div>
{/* Bottom Info Bar - Tags as icons */}
<div className="mt-auto flex min-h-7 shrink-0 items-center gap-1 px-4 py-1">
{deps_plugins && deps_plugins.length > 0 && (
<>
{visibleDepsPlugins.map((depsPlugin, index) => (
<div
key={`${id}-depsPlugin-${index}`}
className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"
title={depsPlugin}
>
<img
className="h-full w-full object-cover"
src={getIconFromMarketPlace(depsPlugin)}
alt={depsPlugin}
/>
</div>
))}
{remainingDepsPluginsCount > 0 && (
<div className="flex items-center justify-center p-0.5">
<span className="system-xs-regular text-text-tertiary">
+
{remainingDepsPluginsCount}
</span>
</div>
)}
</>
)}
</div>
</div>
)
}
const TemplateCard = React.memo(TemplateCardComponent)
export default TemplateCard

View File

@@ -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 <SearchPage />
return <ListWrapper showInstallButton={showInstallButton} />
}
export default MarketplaceContent

View File

@@ -0,0 +1,21 @@
'use client'
import { useSearchTab } from './atoms'
import { Description } from './description'
import SearchResultsHeader from './search-results-header'
type MarketplaceHeaderProps = {
descriptionClassName?: string
marketplaceNav?: React.ReactNode
}
const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => {
const [searchTab] = useSearchTab()
if (searchTab)
return <SearchResultsHeader marketplaceNav={marketplaceNav} />
return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} />
}
export default MarketplaceHeader

View File

@@ -0,0 +1,21 @@
import type { ComponentType } from 'react'
import {
RiBrain2Line,
RiDatabase2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { PluginCategoryEnum } from '../types'
export type PluginTypeIconComponent = ComponentType<{ className?: string }>
export const MARKETPLACE_TYPE_ICON_COMPONENTS: Record<PluginCategoryEnum, PluginTypeIconComponent> = {
[PluginCategoryEnum.tool]: RiHammerLine,
[PluginCategoryEnum.model]: RiBrain2Line,
[PluginCategoryEnum.datasource]: RiDatabase2Line,
[PluginCategoryEnum.trigger]: TriggerIcon,
[PluginCategoryEnum.agent]: RiSpeakAiLine,
[PluginCategoryEnum.extension]: RiPuzzle2Line,
}

View File

@@ -1,105 +0,0 @@
'use client'
import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n'
import {
RiArchive2Line,
RiBrain2Line,
RiDatabase2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { cn } from '@/utils/classnames'
import { searchModeAtom, useActivePluginType } from './atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
type PluginTypeSwitchProps = {
className?: string
}
const PluginTypeSwitch = ({
className,
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const options: Array<{
value: ActivePluginType
text: string
icon: React.ReactNode | null
}> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }),
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: <RiBrain2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: <RiHammerLine className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: <RiDatabase2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: <TriggerIcon className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: <RiSpeakAiLine className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: <RiPuzzle2Line className="mr-1.5 h-4 w-4" />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: <RiArchive2Line className="mr-1.5 h-4 w-4" />,
},
]
return (
<div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
className,
)}
>
{
options.map(option => (
<div
key={option.value}
className={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',
activePluginType === option.value && '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',
)}
onClick={() => {
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
}
}}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default PluginTypeSwitch

View File

@@ -1,23 +1,37 @@
import type { PluginsSearchParams } 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 } 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' },
@@ -30,6 +44,59 @@ export function useMarketplacePlugins(
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: options?.enabled !== false && queryParams !== undefined,
})
}
export function useMarketplaceTemplates(
queryParams: TemplateSearchParams | undefined,
options?: { enabled?: boolean },
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(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: 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,
})
}

View File

@@ -1,8 +1,11 @@
import type { Tag } from '@/app/components/plugins/hooks'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../types'
import SearchBox from '../index'
import SearchBoxWrapper from '../search-box-wrapper'
import SearchDropdown from '../search-dropdown'
import MarketplaceTrigger from '../trigger/marketplace'
import ToolSelectorTrigger from '../trigger/tool-selector'
@@ -13,34 +16,103 @@ import ToolSelectorTrigger from '../trigger/tool-selector'
// Mock i18n translation hook
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
t: (key: string, options?: { ns?: string, num?: number, author?: string }) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key
const translations: Record<string, string> = {
'pluginTags.allTags': 'All Tags',
'pluginTags.searchTags': 'Search tags',
'plugin.searchPlugins': 'Search plugins',
'plugin.install': `${options?.num || 0} installs`,
'plugin.marketplace.searchDropdown.plugins': 'Plugins',
'plugin.marketplace.searchDropdown.showAllResults': 'Show all search results',
'plugin.marketplace.searchDropdown.enter': 'Enter',
'plugin.marketplace.searchDropdown.byAuthor': `by ${options?.author || ''}`,
'plugin.marketplace.searchDropdown.noMatchesTitle': 'No matches',
'plugin.marketplace.searchDropdown.noMatchesDesc': 'Try different filter options.',
}
return translations[fullKey] || key
},
}),
useLocale: () => 'en-US',
}))
vi.mock('ahooks', () => ({
useDebounce: (value: string) => value,
}))
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('jotai', async () => {
const actual = await vi.importActual<typeof import('jotai')>('jotai')
return {
...actual,
useAtomValue: () => false,
useSetAtom: () => vi.fn(),
}
})
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string> | string) => {
if (typeof value === 'string')
return value
return value.en_US || Object.values(value)[0] || ''
},
}))
// Mock marketplace state hooks
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
const {
mockSearchText,
mockHandleSearchTextChange,
mockSetSearchTab,
mockFilterPluginTags,
mockHandleFilterPluginTagsChange,
mockActivePluginCategory,
mockSortValue,
} = vi.hoisted(() => {
return {
mockSearchPluginText: '',
mockHandleSearchPluginTextChange: vi.fn(),
mockSearchText: '',
mockHandleSearchTextChange: vi.fn(),
mockSetSearchTab: vi.fn(),
mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(),
mockActivePluginCategory: 'all',
mockSortValue: {
sortBy: 'install_count',
sortOrder: 'DESC',
},
}
})
vi.mock('../../atoms', () => ({
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
useMarketplacePluginSortValue: () => mockSortValue,
useMarketplaceTemplateSortValue: () => ({ sortBy: 'usage_count', sortOrder: 'DESC' }),
useActiveSort: () => [mockSortValue, vi.fn()],
useActiveSortValue: () => mockSortValue,
useCreationType: () => 'plugins',
useSearchTab: () => ['', mockSetSearchTab],
isMarketplacePlatformAtom: {},
searchModeAtom: {},
}))
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../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' },
@@ -60,8 +132,64 @@ vi.mock('@/app/components/plugins/hooks', () => ({
tags: mockTags,
tagsMap: mockTagsMap,
}),
useCategories: () => ({
categoriesMap: {
'tool': { name: 'tool', label: 'Tool' },
'model': { name: 'model', label: 'Model' },
'datasource': { name: 'datasource', label: 'Data Source' },
'trigger': { name: 'trigger', label: 'Trigger' },
'agent-strategy': { name: 'agent-strategy', label: 'Agent Strategy' },
'extension': { name: 'extension', label: 'Extension' },
'bundle': { name: 'bundle', label: 'Bundle' },
},
}),
}))
let mockDropdownPlugins: Plugin[] = []
vi.mock('../../query', () => ({
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,
}),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'dropbox',
author: 'dropbox',
name: 'dropbox-search',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-1',
icon: 'https://example.com/icon.png',
verified: false,
label: { en_US: 'Dropbox search' },
brief: { en_US: 'Interact with Dropbox files.' },
description: { en_US: 'Interact with Dropbox files.' },
introduction: '',
repository: '',
category: PluginCategoryEnum.tool,
install_count: 206,
endpoint: {
settings: [],
},
tags: [],
badges: [],
verification: {
authorized_category: 'community',
},
from: 'marketplace',
...overrides,
})
// Mock portal-to-follow-elem with shared open state
let mockPortalOpenState = false
@@ -115,6 +243,8 @@ describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockDropdownPlugins = []
mockRouterPush.mockReset()
})
// ================================
@@ -424,6 +554,84 @@ describe('SearchBox', () => {
expect(onSearchChange).toHaveBeenCalledWith(' ')
})
})
// ================================
// Submission Tests
// ================================
describe('Submission', () => {
it('should call onSearchSubmit when pressing Enter', () => {
const onSearchSubmit = vi.fn()
render(<SearchBox {...defaultProps} onSearchSubmit={onSearchSubmit} />)
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSearchSubmit).toHaveBeenCalledTimes(1)
})
})
})
// ================================
// SearchDropdown Component Tests
// ================================
describe('SearchDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin items and metadata', () => {
render(
<SearchDropdown
query="dropbox"
plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={vi.fn()}
/>,
)
expect(screen.getByText('Plugins')).toBeInTheDocument()
expect(screen.getByText('Dropbox search')).toBeInTheDocument()
expect(screen.getByText('Tool')).toBeInTheDocument()
expect(screen.getByText('206 installs')).toBeInTheDocument()
})
it('should render empty state when no results', () => {
render(
<SearchDropdown
query="non-existent"
plugins={[]}
templates={[]}
creators={[]}
onShowAll={vi.fn()}
/>,
)
expect(screen.getByText('No matches')).toBeInTheDocument()
expect(screen.getByText('Try different filter options.')).toBeInTheDocument()
expect(screen.queryByText('Show all search results')).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onShowAll when clicking show all results', () => {
const onShowAll = vi.fn()
render(
<SearchDropdown
query="dropbox"
plugins={[createPlugin()]}
templates={[]}
creators={[]}
onShowAll={onShowAll}
/>,
)
fireEvent.click(screen.getByText('Show all search results'))
expect(onShowAll).toHaveBeenCalledTimes(1)
})
})
})
// ================================
@@ -433,6 +641,8 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockDropdownPlugins = []
mockRouterPush.mockReset()
})
describe('Rendering', () => {
@@ -442,28 +652,56 @@ describe('SearchBoxWrapper', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render in marketplace mode', () => {
const { container } = render(<SearchBoxWrapper />)
it('should apply custom wrapper class to the input wrapper', () => {
const { container } = render(<SearchBoxWrapper wrapperClassName="custom-wrapper" />)
expect(container.querySelector('.rounded-xl')).toBeInTheDocument()
})
it('should apply correct wrapper classes', () => {
const { container } = render(<SearchBoxWrapper />)
// Check for z-[11] class from wrapper
expect(container.querySelector('.z-\\[11\\]')).toBeInTheDocument()
expect(container.querySelector('.custom-wrapper')).toBeInTheDocument()
})
})
describe('Hook Integration', () => {
it('should call handleSearchPluginTextChange when search changes', () => {
it('should not commit search when input changes', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
})
it('should commit search when pressing Enter', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
expect(mockSetSearchTab).toHaveBeenCalledWith('all')
})
it('should clear committed search when input is emptied and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
// Focus, type something, then clear and blur
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'test query' } })
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('')
})
it('should not clear committed search when input has content and blurred', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'still has text' } })
fireEvent.blur(input)
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
})
})
@@ -471,7 +709,7 @@ describe('SearchBoxWrapper', () => {
it('should use translation for placeholder', () => {
render(<SearchBoxWrapper />)
expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument()
expect(screen.getByPlaceholderText('searchInMarketplace')).toBeInTheDocument()
})
})
})

View File

@@ -8,6 +8,9 @@ import TagsFilter from './tags-filter'
type SearchBoxProps = {
search: string
onSearchChange: (search: string) => void
onSearchSubmit?: () => void
onSearchFocus?: () => void
onSearchBlur?: () => void
wrapperClassName?: string
inputClassName?: string
tags: string[]
@@ -22,6 +25,9 @@ type SearchBoxProps = {
const SearchBox = ({
search,
onSearchChange,
onSearchSubmit,
onSearchFocus,
onSearchBlur,
wrapperClassName,
inputClassName,
tags,
@@ -58,6 +64,12 @@ const SearchBox = ({
onChange={(e) => {
onSearchChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
onSearchSubmit?.()
}}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
placeholder={placeholder}
/>
{
@@ -89,6 +101,12 @@ const SearchBox = ({
onChange={(e) => {
onSearchChange(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
onSearchSubmit?.()
}}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
placeholder={placeholder}
/>
{

View File

@@ -1,25 +1,151 @@
'use client'
import type { UnifiedSearchParams } from '../types'
import { useTranslation } from '#i18n'
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index'
import { useDebounce } from 'ahooks'
import { useAtomValue, useSetAtom } from 'jotai'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import {
isMarketplacePlatformAtom,
searchModeAtom,
useSearchTab,
useSearchText,
} from '../atoms'
import { useMarketplaceUnifiedSearch } from '../query'
import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils'
import SearchDropdown from './search-dropdown'
const SearchBoxWrapper = () => {
type SearchBoxWrapperProps = {
wrapperClassName?: string
inputClassName?: string
includeSource?: boolean
}
const SearchBoxWrapper = ({
wrapperClassName,
inputClassName,
includeSource = true,
}: SearchBoxWrapperProps) => {
const isMarketplacePlatform = useAtomValue(isMarketplacePlatformAtom)
const { t } = useTranslation()
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
const [searchText, handleSearchTextChange] = useSearchText()
const [, setSearchTab] = useSearchTab()
const setSearchMode = useSetAtom(searchModeAtom)
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 router = useRouter()
const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => {
if (!hasDraft)
return undefined
return {
query: debouncedDraft.trim(),
scope: ['plugins', 'templates', 'creators'],
page_size: 5,
}
}, [debouncedDraft, hasDraft])
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 = (queryOverride?: string) => {
const trimmed = (queryOverride ?? draftSearch).trim()
if (!trimmed)
return
if (isMarketplacePlatform) {
router.push(`/search/all/?q=${encodeURIComponent(trimmed)}`)
}
else {
handleSearchTextChange(trimmed)
setSearchTab('all')
setSearchMode(true)
}
setIsFocused(false)
}
const inputValue = isFocused ? draftSearch : committedSearch
const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
return (
<SearchBox
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
inputClassName="w-full"
search={searchPluginText}
onSearchChange={handleSearchPluginTextChange}
tags={filterPluginTags}
onTagsChange={handleFilterPluginTagsChange}
placeholder={t('searchPlugins', { ns: 'plugin' })}
usedInMarketplace
/>
<PortalToFollowElem
placement="bottom-start"
offset={8}
open={isDropdownOpen}
onOpenChange={setIsFocused}
>
<PortalToFollowElemTrigger asChild>
<div>
<Input
wrapperClassName={cn('w-[100px] min-w-[80px] shrink-0 rounded-lg sm:w-[160px] md:w-[200px] lg:w-[300px]', wrapperClassName)}
className={cn('h-9 bg-components-input-bg-normal', inputClassName)}
showLeftIcon
value={inputValue}
placeholder={t('searchInMarketplace', { ns: 'plugin' })}
onChange={(e) => {
setDraftSearch(e.target.value)
}}
onFocus={() => {
setDraftSearch(committedSearch)
setIsFocused(true)
}}
onBlur={() => {
if (!isHoveringDropdown) {
if (!draftSearch.trim()) {
handleSearchTextChange('')
setSearchMode(null)
}
setIsFocused(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleSubmit()
}}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-[1001]"
onMouseEnter={() => setIsHoveringDropdown(true)}
onMouseLeave={() => setIsHoveringDropdown(false)}
onMouseDown={(event) => {
event.preventDefault()
}}
>
<SearchDropdown
query={debouncedDraft.trim()}
plugins={dropdownPlugins}
templates={dropdownTemplates}
creators={dropdownCreators}
includeSource={includeSource}
onShowAll={() => handleSubmit(debouncedDraft)}
isLoading={dropdownQuery.isLoading}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@@ -0,0 +1,327 @@
import type { Creator, Template } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArrowRightLine, RiFilter3Line } from '@remixicon/react'
import { Fragment } from 'react'
import AppIcon from '@/app/components/base/app-icon'
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 { formatUsedCount } from '@/utils/template'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
import { buildMarketplaceHref, getCreatorAvatarUrl, getPluginDetailLinkInMarketplace, getTemplateIconUrl } from '../../utils'
const DROPDOWN_PANEL = 'w-[472px] max-h-[710px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
const SectionDivider = () => (
<div className="border-t border-divider-subtle" />
)
const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
<div className="p-1">
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary">{title}</div>
<div className="flex flex-col">{children}</div>
</div>
)
const EmptyState = ({ title, description }: { title: string, description: string }) => (
<div className="flex flex-col items-center gap-2 px-3 py-6">
<RiFilter3Line className="h-6 w-6 text-text-empty-state-icon" />
<div className="flex flex-col items-center gap-1 text-center">
<div className="system-md-medium text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
)
const DropdownItem = ({ href, icon, children }: {
href: string
icon: React.ReactNode
children: React.ReactNode
}) => (
<a className="flex gap-1 rounded-lg py-1 pl-3 pr-1 hover:bg-state-base-hover" href={href}>
{icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 p-1">{children}</div>
</a>
)
const IconBox = ({ shape, size = 'sm', className, style, children }: {
shape: 'rounded-lg' | 'rounded-full'
size?: 'sm' | 'md'
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) => (
<div
className={cn(
ICON_BOX_BASE,
shape,
size === 'sm' ? 'h-7 w-7' : 'h-8 w-8',
className,
)}
style={style}
>
{children}
</div>
)
const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => (
<div className="flex items-center gap-1.5 pt-1 text-text-tertiary">
{items.filter(Boolean).map((item, i) => (
<Fragment key={i}>
{i > 0 && <span className="system-xs-regular">·</span>}
{typeof item === 'string' ? <span className="system-xs-regular">{item}</span> : item}
</Fragment>
))}
</div>
)
type SearchDropdownProps = {
query: string
plugins: Plugin[]
templates: Template[]
creators: Creator[]
includeSource?: boolean
onShowAll: () => void
isLoading?: boolean
}
const SearchDropdown = ({
query,
plugins,
templates,
creators,
includeSource = true,
onShowAll,
isLoading = false,
}: SearchDropdownProps) => {
const { t } = useTranslation()
const getValueFromI18nObject = useRenderI18nObject()
const { categoriesMap } = useCategories(true)
const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0
// Collect rendered sections with dividers between them
const sections: React.ReactNode[] = []
if (templates.length > 0) {
sections.push(
<TemplatesSection
key="templates"
templates={templates}
includeSource={includeSource}
t={t}
/>,
)
}
if (plugins.length > 0) {
sections.push(
<PluginsSection
key="plugins"
plugins={plugins}
getValueFromI18nObject={getValueFromI18nObject}
categoriesMap={categoriesMap}
t={t}
/>,
)
}
if (creators.length > 0) {
sections.push(
<CreatorsSection
key="creators"
creators={creators}
includeSource={includeSource}
t={t}
/>,
)
}
return (
<div className={DROPDOWN_PANEL}>
<div className="flex flex-col">
{isLoading && !hasResults && (
<div className="flex items-center justify-center py-6">
<Loading />
</div>
)}
{!isLoading && !hasResults && (
<EmptyState
title={t('marketplace.searchDropdown.noMatchesTitle', { ns: 'plugin' })}
description={t('marketplace.searchDropdown.noMatchesDesc', { ns: 'plugin' })}
/>
)}
{sections.map((section, i) => (
<Fragment key={i}>
{i > 0 && <SectionDivider />}
{section}
</Fragment>
))}
</div>
{hasResults && (
<div className="border-t border-divider-subtle p-1">
<button
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-state-base-hover"
onClick={onShowAll}
type="button"
>
<span className="system-sm-medium text-text-accent">
{t('marketplace.searchDropdown.showAllResults', { ns: 'plugin', query })}
</span>
<span className="flex items-center">
<span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden">
{t('marketplace.searchDropdown.enter', { ns: 'plugin' })}
</span>
<RiArrowRightLine className="hidden h-[18px] w-[18px] text-text-accent group-hover:block" />
</span>
</button>
</div>
)}
</div>
)
}
/* ---------- Templates Section ---------- */
function TemplatesSection({ templates, includeSource, t }: {
templates: Template[]
includeSource: boolean
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('templates', { ns: 'plugin' })}>
{templates.map((template) => {
const descriptionText = template.overview
const formattedUsedCount = formatUsedCount(template.usage_count, { precision: 0, rounding: 'floor' })
const usedLabel = t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })
const iconUrl = getTemplateIconUrl(template)
return (
<DropdownItem
key={template.id}
href={buildMarketplaceHref(
`/template/${encodeURIComponent(template.publisher_handle)}/${encodeURIComponent(template.template_name)}`,
{ templateId: template.id },
includeSource,
)}
icon={(
<div className="flex shrink-0 items-start py-1">
<AppIcon
size="small"
iconType={iconUrl ? 'image' : 'emoji'}
icon={iconUrl ? undefined : (template.icon || '📄')}
imageUrl={iconUrl || undefined}
background={template.icon_background || undefined}
/>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{template.template_name}</div>
{!!descriptionText && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{descriptionText}</div>
)}
<ItemMeta
items={[
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.publisher_handle }),
usedLabel,
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Plugins Section ---------- */
function PluginsSection({ plugins, getValueFromI18nObject, categoriesMap, t }: {
plugins: Plugin[]
getValueFromI18nObject: ReturnType<typeof useRenderI18nObject>
categoriesMap: Record<string, { label: string }>
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
{plugins.map((plugin) => {
const title = getValueFromI18nObject(plugin.label) || plugin.name
const description = getValueFromI18nObject(plugin.brief) || ''
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
const author = plugin.org || plugin.author || ''
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
const categoryNode = (
<div className="flex items-center gap-1">
{TypeIcon && <TypeIcon className="h-[14px] w-[14px] text-text-tertiary" />}
<span className="system-xs-regular">{categoryLabel}</span>
</div>
)
return (
<DropdownItem
key={`${plugin.org}/${plugin.name}`}
href={getPluginDetailLinkInMarketplace(plugin)}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg">
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
</IconBox>
</div>
)}
>
<div className="system-md-medium truncate text-text-primary">{title}</div>
{!!description && (
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{description}</div>
)}
<ItemMeta
items={[
categoryNode,
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
installLabel,
]}
/>
</DropdownItem>
)
})}
</DropdownSection>
)
}
/* ---------- Creators Section ---------- */
function CreatorsSection({ creators, includeSource, t }: {
creators: Creator[]
includeSource: boolean
t: ReturnType<typeof useTranslation>['t']
}) {
return (
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
{creators.map(creator => (
<a
key={creator.unique_handle}
className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover"
href={buildMarketplaceHref(`/creators/${creator.unique_handle}`, undefined, includeSource)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-[0.5px] border-divider-regular">
<img
className="h-full w-full object-cover"
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={creator.display_name}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-px">
<div className="system-md-medium truncate text-text-primary">{creator.display_name}</div>
<div className="system-xs-regular truncate text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</a>
))}
</DropdownSection>
)
}
export default SearchDropdown

View File

@@ -0,0 +1,2 @@
export { default as MarketplaceTrigger } from './marketplace'
export { default as ToolSelectorTrigger } from './tool-selector'

View File

@@ -0,0 +1,15 @@
export type LanguageOption = {
value: string
label: string
nativeLabel: string
}
export const LANGUAGE_OPTIONS: LanguageOption[] = [
{ value: 'en', label: 'English', nativeLabel: 'English' },
{ value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '简体中文' },
{ value: 'zh-Hant', label: 'Traditional Chinese', nativeLabel: '繁體中文' },
{ value: 'ja', label: 'Japanese', nativeLabel: '日本語' },
{ value: 'es', label: 'Spanish', nativeLabel: 'Español' },
{ value: 'fr', label: 'French', nativeLabel: 'Français' },
{ value: 'ko', label: 'Korean', nativeLabel: '한국어' },
]

View File

@@ -0,0 +1,59 @@
'use client'
import type { Creator } from '../types'
import { useTranslation } from '#i18n'
import { getCreatorAvatarUrl } from '../utils'
type CreatorCardProps = {
creator: Creator
}
const CreatorCard = ({ creator }: CreatorCardProps) => {
const { t } = useTranslation()
const href = `/creator/${creator.unique_handle}`
const displayName = creator.display_name || creator.name
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-2 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg p-4 transition-colors hover:bg-state-base-hover"
>
<div className="flex items-center gap-3">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-full border border-components-panel-border-subtle bg-background-default-dodge">
<img
src={getCreatorAvatarUrl(creator.unique_handle)}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="system-md-medium truncate text-text-primary">{displayName}</div>
<div className="system-sm-regular text-text-tertiary">
@
{creator.unique_handle}
</div>
</div>
</div>
{!!creator.description && (
<div className="system-sm-regular line-clamp-2 text-text-secondary">
{creator.description}
</div>
)}
{(creator.plugin_count !== undefined || creator.template_count !== undefined) && (
<div className="system-xs-regular text-text-tertiary">
{creator.plugin_count || 0}
{' '}
{t('plugins', { ns: 'plugin' }).toLowerCase()}
{' · '}
{creator.template_count || 0}
{' '}
{t('templates', { ns: 'plugin' }).toLowerCase()}
</div>
)}
</a>
)
}
export default CreatorCard

View File

@@ -0,0 +1,167 @@
'use client'
import {
RiArrowDownSLine,
RiCheckLine,
RiCloseCircleFill,
} from '@remixicon/react'
import { useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
export type FilterOption = {
value: string
label: string
}
type FilterChipProps = {
label: string
options: FilterOption[]
value: string[]
onChange: (value: string[]) => void
multiple?: boolean
searchable?: boolean
searchPlaceholder?: string
}
const FilterChip = ({
label,
options,
value,
onChange,
multiple = true,
searchable = false,
searchPlaceholder = '',
}: FilterChipProps) => {
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const hasSelected = value.length > 0
const filteredOptions = searchable
? options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
: options
const getSelectedLabels = () => {
return value
.map(v => options.find(o => o.value === v)?.label)
.filter(Boolean)
.slice(0, 2)
.join(', ')
}
const handleSelect = (optionValue: string) => {
if (multiple) {
if (value.includes(optionValue))
onChange(value.filter(v => v !== optionValue))
else
onChange([...value, optionValue])
}
else {
onChange([optionValue])
setOpen(false)
}
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onChange([])
}
return (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
<div className={cn(
'flex h-8 cursor-pointer select-none items-center gap-0 rounded-lg px-2 py-1',
!hasSelected && 'bg-components-input-bg-normal text-text-tertiary',
!hasSelected && open && 'bg-state-base-hover',
hasSelected && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
)}
>
<div className="flex items-center gap-1 p-1">
{!hasSelected && (
<span className="system-sm-regular text-text-tertiary">{label}</span>
)}
{hasSelected && (
<>
<span className="system-sm-regular text-text-tertiary">{label}</span>
<span className="system-sm-medium text-text-secondary">
{getSelectedLabels()}
</span>
{value.length > 2 && (
<span className="system-xs-medium text-text-tertiary">
+
{value.length - 2}
</span>
)}
</>
)}
</div>
{hasSelected && (
<RiCloseCircleFill
className="size-4 shrink-0 text-text-quaternary"
onClick={handleClear}
/>
)}
{!hasSelected && (
<RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
{searchable && (
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={searchPlaceholder}
/>
</div>
)}
<div className="max-h-[448px] overflow-y-auto p-1">
{filteredOptions.map(option => (
<div
key={option.value}
className="flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => handleSelect(option.value)}
>
{multiple && (
<Checkbox
className="mr-1"
checked={value.includes(option.value)}
/>
)}
<div className="system-sm-medium flex-1 px-1 text-text-secondary">
{option.label}
</div>
{!multiple && value.includes(option.value) && (
<RiCheckLine className="size-4 text-text-accent" />
)}
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default FilterChip

View File

@@ -0,0 +1,286 @@
'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 {
useMarketplacePluginSortValue,
useMarketplaceTemplateSortValue,
useSearchFilterCategories,
useSearchFilterLanguages,
useSearchFilterTags,
useSearchFilterType,
useSearchTab,
useSearchText,
} from '../atoms'
import { CATEGORY_ALL, 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'
import PluginFilters from './plugin-filters'
import TemplateFilters from './template-filters'
const PAGE_SIZE = 40
const ALL_TAB_PREVIEW_SIZE = 8
const ZERO_WIDTH_SPACE = '\u200B'
// type SortValue = { sortBy: string, sortOrder: string }
// 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 pluginSort = useMarketplacePluginSortValue()
const templateSort = useMarketplaceTemplateSortValue()
// Search-page-specific filters
const [searchFilterCategories] = useSearchFilterCategories()
const [searchFilterLanguages] = useSearchFilterLanguages()
const [searchFilterType] = useSearchFilterType()
const [searchFilterTags] = useSearchFilterTags()
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
const pluginsParams = useMemo(() => {
if (!hasQuery)
return undefined
const category = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
? searchFilterType
: undefined
const tags = searchTab === 'plugins' && searchFilterTags.length > 0
? searchFilterTags
: undefined
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by: pluginSort.sortBy,
sort_order: pluginSort.sortOrder,
category,
tags,
type: getPluginFilterType(category || PLUGIN_TYPE_SEARCH_MAP.all),
} as PluginsSearchParams
}, [hasQuery, query, searchTab, pluginSort, searchFilterType, searchFilterTags])
const templatesParams = useMemo(() => {
if (!hasQuery)
return undefined
const categories = searchTab === 'templates' && searchFilterCategories.length > 0
? searchFilterCategories
: undefined
const languages = searchTab === 'templates' && searchFilterLanguages.length > 0
? searchFilterLanguages
: undefined
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by: templateSort.sortBy,
sort_order: templateSort.sortOrder,
categories,
languages,
}
}, [hasQuery, query, searchTab, templateSort, searchFilterCategories, searchFilterLanguages])
const creatorsParams = useMemo(() => {
if (!hasQuery)
return undefined
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
// sort_by,
// sort_order,
}
}, [hasQuery, query, searchTab])
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 (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(plugin => (
<CardWrapper key={`${plugin.org}/${plugin.name}`} plugin={plugin} showInstallButton={false} />
))}
</div>
)
}
const renderTemplatesSection = (items: Template[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(template => (
<div key={template.id}>
<TemplateCard template={template} />
</div>
))}
</div>
)
}
const renderCreatorsSection = (items: Creator[], limit?: number) => {
const toShow = limit ? items.slice(0, limit) : items
if (toShow.length === 0)
return null
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{toShow.map(creator => (
<CreatorCard key={creator.unique_handle} creator={creator} />
))}
</div>
)
}
const renderAllTab = () => (
<div className="flex flex-col gap-8 py-4">
{templates.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('templates', { ns: 'plugin' })}
</h3>
{renderTemplatesSection(templates, ALL_TAB_PREVIEW_SIZE)}
</section>
)}
{plugins.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('plugins', { ns: 'plugin' })}
</h3>
{renderPluginsSection(plugins, ALL_TAB_PREVIEW_SIZE)}
</section>
)}
{creators.length > 0 && (
<section>
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
{t('marketplace.searchFilterCreators', { ns: 'plugin' })}
</h3>
{renderCreatorsSection(creators, ALL_TAB_PREVIEW_SIZE)}
</section>
)}
{!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && (
<Empty />
)}
</div>
)
const renderTab = <T,>(
items: T[],
isItemLoading: boolean,
renderSection: (items: T[]) => React.ReactNode,
emptyText?: string,
) => {
if (items.length === 0 && !isItemLoading)
return <Empty text={emptyText} />
return (
<div className="py-4">
{renderSection(items)}
</div>
)
}
return (
<div
style={{
scrollbarGutter: 'stable',
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
}}
className="relative flex grow flex-col bg-background-default-subtle px-12 pt-2"
>
<div className="mb-4 flex items-center justify-between pt-3">
<div className="flex items-center gap-2">
<SegmentedControl
size="large"
activeState="accentLight"
value={searchTab}
onChange={v => setSearchTab(v as SearchTab)}
options={tabOptions}
/>
{searchTab === 'templates' && <TemplateFilters />}
{searchTab === 'plugins' && <PluginFilters />}
</div>
{(searchTab === 'templates' || searchTab === 'plugins') && <SortDropdown />}
</div>
{isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)}
{!isLoading && (
<>
{searchTab === 'all' && renderAllTab()}
{searchTab === 'plugins' && renderTab(plugins, pluginsQuery.isLoading, renderPluginsSection)}
{searchTab === 'templates' && renderTab(templates, templatesQuery.isLoading, renderTemplatesSection, t('marketplace.noTemplateFound', { ns: 'plugin' }))}
{searchTab === 'creators' && renderTab(creators, creatorsQuery.isLoading, renderCreatorsSection, t('marketplace.noCreatorFound', { ns: 'plugin' }))}
</>
)}
{isFetchingNextPage && <Loading className="my-3" />}
</div>
)
}
export default SearchPage

View File

@@ -0,0 +1,60 @@
'use client'
import type { FilterOption } from './filter-chip'
import { useTranslation } from '#i18n'
import { useMemo } from 'react'
import { useTags } from '@/app/components/plugins/hooks'
import { useSearchFilterTags, useSearchFilterType } from '../atoms'
import { usePluginCategoryText } from '../category-switch/category-text'
import { CATEGORY_ALL, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import FilterChip from './filter-chip'
const PluginFilters = () => {
const { t } = useTranslation()
const [searchType, setSearchType] = useSearchFilterType()
const [searchTags, setSearchTags] = useSearchFilterTags()
const getPluginCategoryText = usePluginCategoryText()
const { tags: tagsList } = useTags()
const typeOptions: FilterOption[] = useMemo(() => {
return Object.values(PLUGIN_TYPE_SEARCH_MAP).map(value => ({
value,
label: getPluginCategoryText(value),
}))
}, [getPluginCategoryText])
const tagOptions: FilterOption[] = useMemo(() => {
return tagsList.map(tag => ({
value: tag.name,
label: tag.label,
}))
}, [tagsList])
const typeValue = searchType === CATEGORY_ALL ? [] : [searchType]
return (
<div className="flex items-center gap-2">
<FilterChip
label={t('marketplace.searchFilterTypes', { ns: 'plugin' })}
options={typeOptions}
value={typeValue}
onChange={(v) => {
const newType = v.length > 0 ? v[v.length - 1] : CATEGORY_ALL
setSearchType(newType === CATEGORY_ALL ? null : newType)
}}
multiple={false}
/>
<FilterChip
label={t('marketplace.searchFilterTags', { ns: 'plugin' })}
options={tagOptions}
value={searchTags}
onChange={v => setSearchTags(v.length ? v : null)}
multiple
searchable
searchPlaceholder={t('searchTags', { ns: 'pluginTags' }) || ''}
/>
</div>
)
}
export default PluginFilters

View File

@@ -0,0 +1,55 @@
'use client'
import type { FilterOption } from './filter-chip'
import { useTranslation } from '#i18n'
import { useMemo } from 'react'
import { useSearchFilterCategories, useSearchFilterLanguages } from '../atoms'
import { useTemplateCategoryText } from '../category-switch/category-text'
import { TEMPLATE_CATEGORY_MAP } from '../constants'
import { LANGUAGE_OPTIONS } from './constants'
import FilterChip from './filter-chip'
const TemplateFilters = () => {
const { t } = useTranslation()
const [categories, setCategories] = useSearchFilterCategories()
const [languages, setLanguages] = useSearchFilterLanguages()
const getTemplateCategoryText = useTemplateCategoryText()
const categoryOptions: FilterOption[] = useMemo(() => {
const entries = Object.entries(TEMPLATE_CATEGORY_MAP).filter(([key]) => key !== 'all')
return entries.map(([, value]) => ({
value,
label: getTemplateCategoryText(value),
}))
}, [getTemplateCategoryText])
const languageOptions: FilterOption[] = useMemo(() => {
return LANGUAGE_OPTIONS.map(lang => ({
value: lang.value,
label: `${lang.nativeLabel}`,
}))
}, [])
return (
<div className="flex items-center gap-2">
<FilterChip
label={t('marketplace.searchFilterCategory', { ns: 'plugin' })}
options={categoryOptions}
value={categories}
onChange={v => setCategories(v.length ? v : null)}
multiple
searchable
searchPlaceholder={t('searchCategories', { ns: 'plugin' })}
/>
<FilterChip
label={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
options={languageOptions}
value={languages}
onChange={v => setLanguages(v.length ? v : null)}
multiple
/>
</div>
)
}
export default TemplateFilters

View File

@@ -1,12 +1,29 @@
import type { inferParserType } from 'nuqs/server'
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
export const CREATION_TYPE = {
plugins: 'plugins',
templates: 'templates',
} as const
export type CreationType = typeof CREATION_TYPE[keyof typeof CREATION_TYPE]
export const SEARCH_TABS = ['all', 'plugins', 'templates', 'creators'] as const
export type SearchTab = (typeof SEARCH_TABS)[number] | ''
export const marketplaceSearchParamsParsers = {
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
// Search-page-specific filters (independent from list-page category/tags)
searchCategories: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
searchLanguages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
searchType: parseAsString.withDefault('all').withOptions({ history: 'replace' }),
searchTags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
// In marketplace, we use path instead of query
category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
creationType: parseAsStringEnum<CreationType>([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
searchTab: parseAsStringEnum<SearchTab>(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
}
export type MarketplaceSearchParams = inferParserType<typeof marketplaceSearchParamsParsers>

View File

@@ -0,0 +1,34 @@
'use client'
import { useTranslation } from '#i18n'
import { useSearchText } from './atoms'
type SearchResultsHeaderProps = {
marketplaceNav?: React.ReactNode
}
const SearchResultsHeader = ({ marketplaceNav }: SearchResultsHeaderProps) => {
const { t } = useTranslation('plugin')
const [searchText] = useSearchText()
return (
<div className="relative px-7 py-4">
{marketplaceNav}
<div className="system-xs-regular mt-8 flex items-center gap-1 px-5 text-text-tertiary ">
<span>{t('marketplace.searchBreadcrumbMarketplace')}</span>
<span className="text-text-quaternary">/</span>
<span>{t('marketplace.searchBreadcrumbSearch')}</span>
</div>
<div className="mt-2 flex items-end gap-2 px-5 ">
<div className="title-4xl-semi-bold text-text-primary">
{t('marketplace.searchResultsFor')}
</div>
<div className="title-4xl-semi-bold relative text-saas-dify-blue-accessible">
<span className="relative z-10">{searchText || ''}</span>
<span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" />
</div>
</div>
</div>
)
}
export default SearchResultsHeader

View File

@@ -1,15 +1,8 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SortDropdown from '../index'
// ================================
// Mock external dependencies only
// ================================
// Mock i18n translation hook
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key
const translations: Record<string, string> = {
'plugin.marketplace.sortBy': 'Sort by',
@@ -27,216 +20,78 @@ vi.mock('#i18n', () => ({
}),
}))
// Mock marketplace atoms with controllable values
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn()
let mockCreationType = 'plugins'
vi.mock('../../atoms', () => ({
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
useActiveSort: () => [mockSort, mockHandleSortChange],
useCreationType: () => mockCreationType,
}))
vi.mock('../../search-params', () => ({
CREATION_TYPE: { plugins: 'plugins', templates: 'templates' },
}))
// Mock portal component with controllable open state
let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpenState = open
return (
<div data-testid="portal-wrapper" data-open={open}>
{children}
</div>
)
return <div data-testid="portal-wrapper" data-open={String(open)}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: {
children: React.ReactNode
onClick: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
// Match actual behavior: only render when portal is open
if (!mockPortalOpenState)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
// ================================
// Test Factory Functions
// ================================
type SortOption = {
value: string
order: string
text: string
}
const createSortOptions = (): SortOption[] => [
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
{ value: 'created_at', order: 'ASC', text: 'First Released' },
]
// ================================
// SortDropdown Component Tests
// ================================
describe('SortDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
mockCreationType = 'plugins'
mockPortalOpenState = false
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<SortDropdown />)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should render sort by label', () => {
it('should render selected plugin sort label by default', () => {
render(<SortDropdown />)
expect(screen.getByText('Sort by')).toBeInTheDocument()
})
it('should render selected option text', () => {
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
})
it('should render arrow down icon', () => {
const { container } = render(<SortDropdown />)
it('should render template sort label when creationType is templates', () => {
mockCreationType = 'templates'
mockSort = { sortBy: 'updated_at', sortOrder: 'DESC' }
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
expect(arrowIcon).toBeInTheDocument()
})
it('should render trigger element with correct styles', () => {
const { container } = render(<SortDropdown />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
})
it('should not render dropdown content when closed', () => {
render(<SortDropdown />)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// ================================
// State Management Tests
// ================================
describe('State Management', () => {
it('should initialize with closed state', () => {
render(<SortDropdown />)
const wrapper = screen.getByTestId('portal-wrapper')
expect(wrapper).toHaveAttribute('data-open', 'false')
})
it('should display correct selected option for install_count DESC', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should display correct selected option for version_updated_at DESC', () => {
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
})
it('should display correct selected option for created_at DESC', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Newly Released')).toBeInTheDocument()
})
it('should display correct selected option for created_at ASC', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
render(<SortDropdown />)
expect(screen.getByText('First Released')).toBeInTheDocument()
})
it('should toggle open state when trigger clicked', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// After click, portal content should be visible
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should close dropdown when trigger clicked again', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
// Open
fireEvent.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close
fireEvent.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// ================================
// User Interactions Tests
// ================================
describe('User Interactions', () => {
it('should open dropdown on trigger click', () => {
describe('Interactions', () => {
it('should open the dropdown when trigger is clicked', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(within(screen.getByTestId('portal-content')).getByText('Recently Updated')).toBeInTheDocument()
})
it('should render all sort options when open', () => {
it('should call handleSortChange with plugin sort option values', () => {
render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
expect(within(content).getByText('First Released')).toBeInTheDocument()
})
it('should call handleSortChange when option clicked', () => {
render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger'))
// Click on "Recently Updated"
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Recently Updated'))
fireEvent.click(within(screen.getByTestId('portal-content')).getByText('Recently Updated'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'version_updated_at',
@@ -244,455 +99,28 @@ describe('SortDropdown', () => {
})
})
it('should call handleSortChange with correct params for Most Popular', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
it('should call handleSortChange with template sort option values', () => {
mockCreationType = 'templates'
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Most Popular'))
fireEvent.click(within(screen.getByTestId('portal-content')).getByText('Most Popular'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'install_count',
sortBy: 'usage_count',
sortOrder: 'DESC',
})
})
it('should call handleSortChange with correct params for Newly Released', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Newly Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'DESC',
})
})
it('should call handleSortChange with correct params for First Released', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'ASC',
})
})
it('should allow selecting currently selected option', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Most Popular'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'install_count',
sortOrder: 'DESC',
})
})
it('should support userEvent for trigger click', async () => {
const user = userEvent.setup()
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
})
// ================================
// Check Icon Tests
// ================================
describe('Check Icon', () => {
it('should show check icon for selected option', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger'))
// Check icon should be present in the dropdown
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should show check icon only for matching sortBy AND sortOrder', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
// "Newly Released" (created_at DESC) should have check icon
// "First Released" (created_at ASC) should NOT have check icon
expect(options.length).toBe(4)
})
it('should not show check icon for different sortOrder with same sortBy', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
// Only one check icon should be visible (for Newly Released, not First Released)
const checkIcons = container.querySelectorAll('.text-text-accent')
expect(checkIcons.length).toBe(1)
})
})
// ================================
// Dropdown Options Structure Tests
// ================================
describe('Dropdown Options Structure', () => {
const sortOptions = createSortOptions()
it('should render 4 sort options', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
expect(options.length).toBe(4)
})
it.each(sortOptions)('should render option: $text', ({ text }) => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText(text)).toBeInTheDocument()
})
it('should render options with unique keys', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
// All options should be rendered (no key conflicts)
expect(options.length).toBe(4)
})
it('should render dropdown container with correct styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const container = content.firstChild as HTMLElement
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
})
it('should render option items with hover styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const option = content.querySelector('.cursor-pointer')
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
// The component falls back to the first option (Most Popular) when sort values are invalid
it('should fallback to default option when sortBy is unknown', () => {
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
render(<SortDropdown />)
// Should fallback to first option "Most Popular"
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should fallback to default option when sortBy is empty', () => {
mockSort = { sortBy: '', sortOrder: 'DESC' }
describe('Selection', () => {
it('should fall back to the first option when the sort does not match any option', () => {
mockSort = { sortBy: 'unknown', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should fallback to default option when sortOrder is unknown', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should render correctly when handleSortChange is a no-op', () => {
mockHandleSortChange.mockImplementation(() => {})
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Recently Updated'))
expect(mockHandleSortChange).toHaveBeenCalled()
})
it('should handle rapid toggle clicks', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
// Rapid clicks
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
// Final state should be open (odd number of clicks)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should handle multiple option selections', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
// Click multiple options
fireEvent.click(within(content).getByText('Recently Updated'))
fireEvent.click(within(content).getByText('Newly Released'))
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
})
})
// ================================
// Context Integration Tests
// ================================
describe('Context Integration', () => {
it('should read sort value from context', () => {
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
})
it('should call context handleSortChange on selection', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'ASC',
})
})
it('should update display when context sort changes', () => {
const { rerender } = render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
// Simulate context change
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
rerender(<SortDropdown />)
expect(screen.getByText('First Released')).toBeInTheDocument()
})
it('should use selector pattern correctly', () => {
render(<SortDropdown />)
// Component should have called useMarketplaceContext with selector functions
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have cursor pointer on trigger', () => {
const { container } = render(<SortDropdown />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
})
it('should have cursor pointer on options', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
expect(options.length).toBeGreaterThan(0)
})
it('should have visible focus indicators via hover styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
expect(option).toBeInTheDocument()
})
})
// ================================
// Translation Tests
// ================================
describe('Translations', () => {
it('should call translation for sortBy label', () => {
render(<SortDropdown />)
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
})
it('should call translation for all sort options', () => {
render(<SortDropdown />)
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
})
})
// ================================
// Portal Component Integration Tests
// ================================
describe('Portal Component Integration', () => {
it('should pass open state to PortalToFollowElem', () => {
render(<SortDropdown />)
const wrapper = screen.getByTestId('portal-wrapper')
expect(wrapper).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(wrapper).toHaveAttribute('data-open', 'true')
})
it('should render trigger content inside PortalToFollowElemTrigger', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
})
it('should render options inside PortalToFollowElemContent', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
})
})
// ================================
// Visual Style Tests
// ================================
describe('Visual Styles', () => {
it('should apply correct trigger container styles', () => {
const { container } = render(<SortDropdown />)
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
expect(triggerDiv).toBeInTheDocument()
})
it('should apply secondary text color to sort by label', () => {
const { container } = render(<SortDropdown />)
const label = container.querySelector('.text-text-secondary')
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('Sort by')
})
it('should apply primary text color to selected option', () => {
const { container } = render(<SortDropdown />)
const selected = container.querySelector('.text-text-primary.system-sm-medium')
expect(selected).toBeInTheDocument()
})
it('should apply tertiary text color to arrow icon', () => {
const { container } = render(<SortDropdown />)
const arrow = container.querySelector('.text-text-tertiary')
expect(arrow).toBeInTheDocument()
})
it('should apply accent text color to check icon when option selected', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should apply blur backdrop to dropdown container', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const container = content.querySelector('.backdrop-blur-sm')
expect(container).toBeInTheDocument()
})
})
// ================================
// All Sort Options Click Tests
// ================================
describe('All Sort Options Click Handlers', () => {
const testCases = [
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
]
it.each(testCases)(
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
({ text, sortBy, sortOrder }) => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText(text))
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
},
)
})
})

View File

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

View File

@@ -1,38 +1,50 @@
import type { PluginsSearchParams } from './types'
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useFilterTemplateLanguages, useMarketplacePluginSortValue, useMarketplaceSearchMode, useMarketplaceTemplateSortValue, useSearchText } from './atoms'
import { CATEGORY_ALL } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
import { CREATION_TYPE } from './search-params'
import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils'
export function useMarketplaceData() {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
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(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()
const sort = useMarketplacePluginSortValue()
const isSearchMode = useMarketplaceSearchMode()
const queryParams = useMemo((): PluginsSearchParams | undefined => {
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(() => {
@@ -44,12 +56,89 @@ export function useMarketplaceData() {
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,
}
}
/**
* Hook for templates marketplace data
* Only fetches templates-related data
*/
export function useTemplatesMarketplaceData(enabled = true) {
// Reuse existing atoms for search and sort
const [searchTextOriginal] = useSearchText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [activeTemplateCategory] = useActiveTemplateCategory()
const [filterTemplateLanguages] = useFilterTemplateLanguages()
// Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
// Template-specific sort value (independent from plugin sort)
const sort = useMarketplaceTemplateSortValue()
// Search mode: when there's search text or non-default category
const isSearchMode = useMarketplaceSearchMode()
// Build query params for search mode
const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode)
return undefined
return {
query: searchText,
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
...(filterTemplateLanguages.length > 0 ? { languages: filterTemplateLanguages } : {}),
}
}, [isSearchMode, searchText, activeTemplateCategory, sort, filterTemplateLanguages])
// Templates search query (for search mode)
const templatesQuery = useMarketplaceTemplates(queryParams, { enabled })
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
// Pagination handler
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
return {
templateCollections: templateCollectionsQuery.data?.templateCollections,
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
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,
}
}
export type PluginsMarketplaceData = ReturnType<typeof usePluginsMarketplaceData>
export type TemplatesMarketplaceData = ReturnType<typeof useTemplatesMarketplaceData>
export type MarketplaceData = PluginsMarketplaceData | TemplatesMarketplaceData
export function isPluginsData(data: MarketplaceData): data is PluginsMarketplaceData {
return 'pluginCollections' in data
}
/**
* Main hook that routes to appropriate data based on creationType
* Returns either plugins or templates data based on URL parameter
*/
export function useMarketplaceData(): MarketplaceData {
const creationType = useCreationType()
const pluginsData = usePluginsMarketplaceData(creationType === CREATION_TYPE.plugins)
const templatesData = useTemplatesMarketplaceData(creationType === CREATION_TYPE.templates)
return creationType === CREATION_TYPE.templates ? templatesData : pluginsData
}

View File

@@ -1,30 +0,0 @@
'use client'
import { cn } from '@/utils/classnames'
import PluginTypeSwitch from './plugin-type-switch'
import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = {
pluginTypeSwitchClassName?: string
}
const StickySearchAndSwitchWrapper = ({
pluginTypeSwitchClassName,
}: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
return (
<div
className={cn(
'mt-4 bg-background-body',
hasCustomTopClass && 'sticky z-10',
pluginTypeSwitchClassName,
)}
>
<SearchBoxWrapper />
<PluginTypeSwitch />
</div>
)
}
export default StickySearchAndSwitchWrapper

View File

@@ -6,7 +6,7 @@ export type SearchParamsFromCollection = {
sort_order?: string
}
export type MarketplaceCollection = {
export type PluginCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
@@ -18,7 +18,7 @@ export type MarketplaceCollection = {
}
export type MarketplaceCollectionsResponse = {
collections: MarketplaceCollection[]
collections: PluginCollection[]
total: number
}
@@ -56,4 +56,164 @@ export type SearchParams = {
q?: string
tags?: string
category?: string
creationType?: string
}
export type TemplateCollection = {
id: string
name: string
label: Record<string, string>
description: Record<string, string>
conditions: string[]
searchable: boolean
search_params?: SearchParamsFromCollection
created_at?: string
updated_at?: string
}
export type Template = {
id: string
index_id: string
version?: string
template_name: string
icon: string
icon_background?: string
icon_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
deps_plugins: string[]
preferred_languages: string[]
publisher_handle: string
publisher_type: string
kind: string
status: string
usage_count: number
created_at: string
updated_at: string
}
export type CreateTemplateCollectionRequest = {
name: string
description: Record<string, string>
label: Record<string, string>
conditions: string[]
searchable: boolean
search_params: SearchParamsFromCollection
}
export type GetCollectionTemplatesRequest = {
categories?: string[]
exclude?: string[]
limit?: number
}
export type AddTemplateToCollectionRequest = {
template_id: string
}
export type BatchAddTemplatesToCollectionRequest = {
template_id: string
}[]
// Creator types
export type Creator = {
id?: string
email: string
name: string
display_name: string
unique_handle: string
display_email: string
description: string
avatar: string
social_links: string[]
status: 'active' | 'inactive'
public?: boolean
plugin_count?: number
template_count?: number
created_at: string
updated_at: string
}
export type CreatorSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
}
export type CreatorSearchResponse = {
creators: Creator[]
total: number
}
export type SyncCreatorProfileRequest = {
email: string
name?: string
display_name?: string
unique_handle: string
display_email?: string
description?: string
avatar?: string
social_links?: string[]
status?: 'active' | 'inactive'
}
// Template Detail (full template info from API, extends Template with extra fields)
export type TemplateDetail = Template & {
publisher_unique_handle: string
creator_email: string
dsl_file_key: string
review_comment: string
}
export type TemplatesListResponse = {
templates: TemplateDetail[]
total: number
}
export type TemplateSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
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 (same as Template)
export type UnifiedTemplateItem = Template
// 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 }
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { buildMarketplaceHref, buildSearchParamsString } from './utils'
describe('buildSearchParamsString', () => {
it('filters undefined and null values', () => {
const query = buildSearchParamsString({
theme: undefined,
language: 'en-US',
templateId: 'tpl-1',
empty: null as unknown as string,
})
expect(query).toBe('language=en-US&templateId=tpl-1')
expect(query).not.toContain('theme=undefined')
expect(query).not.toContain('empty=')
})
})
describe('buildMarketplaceHref', () => {
it('returns relative path with filtered query when includeSource is false', () => {
const href = buildMarketplaceHref('/template/foo/bar', {
theme: undefined,
language: 'en-US',
templateId: 'tpl-1',
}, false)
expect(href).toBe('/template/foo/bar?language=en-US&templateId=tpl-1')
expect(href).not.toContain('theme=undefined')
})
it('delegates to marketplace source URL when includeSource is true', () => {
const href = buildMarketplaceHref('/template/foo/bar', {
language: 'en-US',
templateId: 'tpl-1',
}, true)
expect(href).toContain('/template/foo/bar?')
expect(href).toContain('source=')
expect(href).toContain('language=en-US')
expect(href).toContain('templateId=tpl-1')
})
})

View File

@@ -1,8 +1,18 @@
import type { ActivePluginType } from './constants'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
Creator,
CreatorSearchParams,
PluginCollection,
PluginsSearchParams,
Template,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
UnifiedCreatorItem,
UnifiedPluginItem,
UnifiedSearchParams,
UnifiedSearchResponse,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@@ -17,12 +27,41 @@ type MarketplaceFetchOptions = {
signal?: AbortSignal
}
/** Get a string key from an item by field name (e.g. plugin_id, id). */
export function getItemKeyByField<T>(item: T, field: keyof T): string {
return String((item as Record<string, unknown>)[field as string])
}
/** Group a flat array into pages for a carousel layout. */
export function buildCarouselPages<T>(items: T[], itemsPerPage: number): T[][] {
const pages: T[][] = []
for (let i = 0; i < items.length; i += itemsPerPage)
pages.push(items.slice(i, i + itemsPerPage))
return pages
}
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 getTemplateIconUrl = (template: { id: string, icon?: string, icon_file_key?: string }): string => {
if (template.icon?.startsWith('http'))
return template.icon
if (template.icon_file_key)
return `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
return ''
}
export const getCreatorAvatarUrl = (uniqueHandle: string) => {
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
}
export const getOrganizationAvatarUrl = (id: string) => {
return `${MARKETPLACE_API_PREFIX}/organizations/${id}/avatar`
}
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
if (bundle.type === 'bundle') {
return {
@@ -42,13 +81,35 @@ export const getFormattedPlugin = (bundle: Plugin): Plugin => {
export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => {
if (plugin.type === 'bundle')
return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params)
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
return getMarketplaceUrl(`/plugin/${plugin.org}/${plugin.name}`, params)
}
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `/bundles/${plugin.org}/${plugin.name}`
return `/plugins/${plugin.org}/${plugin.name}`
return `/plugin/${plugin.org}/${plugin.name}`
}
export const buildSearchParamsString = (params?: Record<string, string | undefined>) => {
const searchParams = new URLSearchParams()
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null)
searchParams.append(key, value)
}
}
return searchParams.toString()
}
export const buildMarketplaceHref = (
path: string,
params: Record<string, string | undefined> | undefined,
includeSource: boolean,
) => {
if (includeSource)
return getMarketplaceUrl(path, params)
const query = buildSearchParamsString(params)
return query ? `${path}?${query}` : path
}
export const getMarketplacePluginsByCollectionId = async (
@@ -59,7 +120,7 @@ export const getMarketplacePluginsByCollectionId = async (
let plugins: Plugin[] = []
try {
const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({
const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({
params: {
collectionId,
},
@@ -81,10 +142,10 @@ export const getMarketplaceCollectionsAndPlugins = async (
query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions,
) => {
let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
let pluginCollections: PluginCollection[] = []
let pluginCollectionPluginsMap: Record<string, Plugin[]> = {}
try {
const marketplaceCollectionsDataJson = await marketplaceClient.collections({
const collectionsDataJson = await marketplaceClient.plugins.collections({
query: {
...query,
page: 1,
@@ -93,22 +154,75 @@ 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,
}
}
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
// TemplateDetail extends Template; just override publisher_handle from the detail-specific field
return {
...template,
publisher_handle: template.publisher_handle || template.publisher_unique_handle || template.creator_email || '',
index_id: template.index_id || template.id,
}
}
export const getMarketplaceTemplateCollectionsAndTemplates = async (
query?: { page?: number, page_size?: number, condition?: string },
options?: MarketplaceFetchOptions,
) => {
let templateCollections: TemplateCollection[] = []
let templateCollectionTemplatesMap: Record<string, Template[]> = {}
try {
const res = await marketplaceClient.templateCollections.list({
query: {
...query,
page: 1,
page_size: 100,
},
}, {
signal: options?.signal,
})
templateCollections = res.data?.collections || []
await Promise.all(templateCollections.map(async (collection) => {
try {
const templatesRes = await marketplaceClient.templateCollections.getTemplates({
params: { collectionName: collection.name },
body: { limit: 20 },
}, { signal: options?.signal })
const templatesData = templatesRes.data?.templates || []
templateCollectionTemplatesMap[collection.name] = templatesData.map(mapTemplateDetailToTemplate)
}
catch {
templateCollectionTemplatesMap[collection.name] = []
}
}))
}
catch {
templateCollections = []
templateCollectionTemplatesMap = {}
}
return {
templateCollections,
templateCollectionTemplatesMap,
}
}
@@ -137,7 +251,7 @@ export const getMarketplacePlugins = async (
} = queryParams
try {
const res = await marketplaceClient.searchAdvanced({
const res = await marketplaceClient.plugins.searchAdvanced({
params: {
kind: type === 'bundle' ? 'bundles' : 'plugins',
},
@@ -170,7 +284,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}`
@@ -183,7 +297,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
@@ -199,7 +313,242 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
}
return {
category,
condition: getMarketplaceListCondition(category),
type: getMarketplaceListFilterType(category),
condition: getPluginCondition(category),
type: getPluginFilterType(category),
}
}
export const getMarketplaceTemplates = async (
queryParams: TemplateSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
templates: TemplateDetail[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
templates: [] as TemplateDetail[],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
languages,
page_size = 40,
} = queryParams
try {
const body = {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
...(categories ? { categories } : {}),
...(languages ? { languages } : {}),
}
const res = await marketplaceClient.templates.searchAdvanced({
body,
}, { signal })
return {
templates: res.data?.templates || [],
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
templates: [],
total: 0,
page: pageParam,
page_size,
}
}
}
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 (identity since UnifiedTemplateItem = Template)
*/
export function mapUnifiedTemplateToTemplate(item: Template): Template {
return item
}
/**
* 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<UnifiedSearchResponse['data'] & { page: number, page_size: number }> => {
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,
}
}
}

View File

@@ -61,7 +61,7 @@ const getDetailUrl = (
return `https://github.com/${repo}`
}
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return getMarketplaceUrl(`/plugin/${author}/${name}`, { language: locale, theme })
return ''
}
@@ -263,7 +263,7 @@ const DetailHeader = ({
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
alternativePluginURL={getMarketplaceUrl(`/plugin/${alternative_plugin_id}`, { language: currentLocale, theme })}
className="mt-3"
/>
)}

View File

@@ -195,7 +195,7 @@ const PluginItem: FC<Props> = ({
{source === PluginSource.marketplace && enable_marketplace
&& (
<>
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
<a href={getMarketplaceUrl(`/plugin/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
<div className="text-text-tertiary system-2xs-medium-uppercase">
{t('from', { ns: 'plugin' })}
{' '}

View File

@@ -610,7 +610,7 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />)
expect(screen.getByText('my-organization')).toBeInTheDocument()
expect(screen.getByText('my-plugin-name')).toBeInTheDocument()
expect(screen.queryByText('my-plugin-name')).not.toBeInTheDocument()
})
it('should display plugin category', () => {
@@ -750,7 +750,7 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />)
expect(screen.getByText('plugin-with-special<chars>!@#$%')).toBeInTheDocument()
expect(screen.getByText('org<script>test</script>')).toBeInTheDocument()
})
it('should handle very long title', () => {

View File

@@ -104,6 +104,10 @@ vi.mock('../install-plugin-dropdown', () => ({
),
}))
vi.mock('../../marketplace/search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
vi.mock('../../install-plugin/install-from-local-package', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-local-modal">
@@ -180,11 +184,7 @@ describe('PluginPage Component', () => {
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />)
// The marketplace content should be visible when enable_marketplace is true and on discover tab
const container = document.getElementById('marketplace-container')
expect(container).toBeInTheDocument()
// Check that marketplace-specific links are shown
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
})
it('should render TabSlider', () => {
@@ -225,9 +225,7 @@ describe('PluginPage Component', () => {
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />)
// Check for marketplace-specific buttons
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
expect(screen.getByText(/requestSubmit/i)).toBeInTheDocument()
})
it('should not show marketplace links when on plugins tab', () => {
@@ -548,12 +546,11 @@ describe('PluginPage Component', () => {
const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />)
// Should show marketplace links when on discover tab
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
// Rerender with same props
rerender(<PluginPageWithContext {...createDefaultProps()} />)
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
})
it('should recognize plugin type tabs as marketplace', () => {
@@ -562,9 +559,8 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />)
// Should show marketplace links when on a plugin type tab
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
expect(screen.getByText(/requestSubmit/i)).toBeInTheDocument()
})
it('should render marketplace content when isExploringMarketplace and enable_marketplace are true', () => {
@@ -572,11 +568,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />)
// The marketplace prop content should be rendered
// Since we mock the marketplace as a div, check it's not hidden
const container = document.getElementById('marketplace-container')
expect(container).toBeInTheDocument()
expect(container).toHaveClass('bg-background-body')
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
})
})
@@ -616,6 +608,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext plugins={null} marketplace={null} />)
expect(document.getElementById('marketplace-container')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-content')).not.toBeInTheDocument()
})
it('should handle rapid tab switches', async () => {
@@ -638,8 +631,8 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />)
// Component should still render but without marketplace content when disabled
expect(document.getElementById('marketplace-container')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-content')).not.toBeInTheDocument()
})
it('should handle file with empty name', async () => {
@@ -731,7 +724,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />)
const container = document.getElementById('marketplace-container')
expect(container).toHaveClass('bg-background-body')
expect(container).toHaveClass('bg-components-panel-bg')
})
it('should have scrollbar-gutter stable style', () => {
@@ -1027,11 +1020,10 @@ describe('PluginPage Integration', () => {
const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />)
// With enable_marketplace: true (default mock), marketplace links should show
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
// Rerender to verify consistent behavior
rerender(<PluginPageWithContext {...createDefaultProps()} />)
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
})
})

Some files were not shown because too many files have changed in this diff Show More