mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
feat(web): add marketplace creator profiles and homepage redesign (#41538)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: fatelei <fatelei@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: 姜涵煦 <hanxujiang@jianghanxudeMacBook-Pro-2.local> Co-authored-by: L1nSn0w <l1nsn0w@qq.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
parent
949f6c4ad4
commit
cb6c04637b
@ -15,6 +15,10 @@ const config: KnipConfig = {
|
||||
'tsslint.config.ts',
|
||||
'dev-proxy.config.ts',
|
||||
'plugins/eslint/index.js',
|
||||
// Public surface consumed by the standalone Marketplace host.
|
||||
// The `!` suffix keeps these entries in `knip --production`.
|
||||
'app/components/plugins/marketplace/standalone/server.ts!',
|
||||
'app/components/plugins/marketplace/standalone/client.ts!',
|
||||
],
|
||||
project: [
|
||||
'**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!',
|
||||
|
||||
@ -1131,7 +1131,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/plugin/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts": {
|
||||
@ -2565,23 +2565,15 @@
|
||||
},
|
||||
"web/app/components/plugins/marketplace/hooks.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/list/list-with-collection.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/query.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
|
||||
|
||||
@ -22,6 +22,10 @@ export type MarketplaceCollection = {
|
||||
search_params?: SearchParamsFromCollection
|
||||
}
|
||||
|
||||
export type MarketplaceTimestamp = string | number
|
||||
export type MarketplaceCreatorStatus = 'pending' | 'active' | 'inactive' | 'deleted'
|
||||
export type MarketplaceOrganizationStatus = 'active' | 'inactive' | 'deleted'
|
||||
|
||||
export type PluginsSearchParams = {
|
||||
query: string
|
||||
page?: number
|
||||
@ -44,6 +48,7 @@ export type CollectionsAndPluginsSearchParams = {
|
||||
condition?: string
|
||||
exclude?: string[]
|
||||
type?: 'plugin' | 'bundle'
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type MarketplaceTemplate = {
|
||||
@ -53,9 +58,65 @@ export type MarketplaceTemplate = {
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_file_key: string
|
||||
publisher_unique_handle: string
|
||||
publisher_unique_handle?: string
|
||||
publisher_handle?: string
|
||||
publisher_type?: string
|
||||
creator_email?: string
|
||||
usage_count: number
|
||||
categories: string[]
|
||||
deps_plugins?: string[]
|
||||
preferred_languages?: string[]
|
||||
badges?: string[]
|
||||
created_at?: MarketplaceTimestamp
|
||||
updated_at?: MarketplaceTimestamp
|
||||
}
|
||||
|
||||
export type MarketplaceCreator = {
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
display_name?: string
|
||||
unique_handle: string
|
||||
display_email?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
background_image?: string
|
||||
social_links?: string[]
|
||||
badges?: string[]
|
||||
verified?: boolean
|
||||
status?: MarketplaceCreatorStatus
|
||||
public?: boolean
|
||||
plugin_count?: number
|
||||
template_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type MarketplaceOrganization = {
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
display_name?: string
|
||||
unique_handle?: string
|
||||
display_email?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
background_image?: string
|
||||
social_links?: string[]
|
||||
badges?: string[]
|
||||
verified?: boolean
|
||||
status?: MarketplaceOrganizationStatus
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type MarketplaceTemplateCollection = {
|
||||
name: string
|
||||
description: Record<string, string>
|
||||
label: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: SearchParamsFromCollection
|
||||
priority: number
|
||||
}
|
||||
|
||||
export type MarketplacePluginCategory =
|
||||
@ -109,6 +170,9 @@ export type MarketplacePlugin = {
|
||||
authorized_category: 'langgenius' | 'partner' | 'community'
|
||||
}
|
||||
from: MarketplacePluginDependencySource
|
||||
created_at?: MarketplaceTimestamp
|
||||
updated_at?: MarketplaceTimestamp
|
||||
version_updated_at?: MarketplaceTimestamp | null
|
||||
}
|
||||
|
||||
export type PluginInfoFromMarketPlace = {
|
||||
@ -154,8 +218,151 @@ export type TemplateDetailResponse = {
|
||||
data: MarketplaceTemplate
|
||||
}
|
||||
|
||||
export type TemplateCollectionsResponse = {
|
||||
data?: {
|
||||
collections?: MarketplaceTemplateCollection[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateCollectionTemplatesResponse = {
|
||||
data?: {
|
||||
templates?: MarketplaceTemplate[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateSearchResponse = {
|
||||
data?: {
|
||||
templates?: MarketplaceTemplate[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DownloadPluginResponse = Blob
|
||||
|
||||
export type CreatorDetailResponse = {
|
||||
code?: number
|
||||
data?: {
|
||||
creator?: MarketplaceCreator
|
||||
}
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export type OrganizationDetailResponse = {
|
||||
code?: number
|
||||
data?: {
|
||||
organization?: MarketplaceOrganization
|
||||
}
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export type PublisherPluginsResponse = {
|
||||
code?: number
|
||||
data?: {
|
||||
plugins?: MarketplacePlugin[]
|
||||
total?: number
|
||||
}
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export type PublisherTemplatesResponse = {
|
||||
code?: number
|
||||
data?: {
|
||||
templates?: MarketplaceTemplate[]
|
||||
total?: number
|
||||
}
|
||||
msg?: string
|
||||
}
|
||||
|
||||
// Banner payload shapes shared by the standalone marketplace and the embedded
|
||||
// console. The banners endpoint output stays `unknown` in the contract because
|
||||
// the delivery format is normalized and runtime-validated in
|
||||
// `web/app/components/plugins/marketplace/home/banners.ts`.
|
||||
export type BannerBase = {
|
||||
id: string
|
||||
title: string
|
||||
sort: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export type BannerRecommendCard = {
|
||||
item_type: 'plugin' | 'template'
|
||||
item_id: string
|
||||
display_name: string
|
||||
icon_url?: string
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
creator?: string
|
||||
badges?: Array<'partner' | 'verified'>
|
||||
link: string
|
||||
card_position: number
|
||||
auto_batch_id?: string | null
|
||||
}
|
||||
|
||||
export type BannerRecommend = BannerBase & {
|
||||
style_type: 'recommend'
|
||||
content: {
|
||||
theme_type: 'newest' | 'hottest' | 'partner'
|
||||
heading?: string
|
||||
subheadings?: string[]
|
||||
description?: string
|
||||
cards: BannerRecommendCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerBlog = BannerBase & {
|
||||
style_type: 'blog'
|
||||
content: {
|
||||
blog_title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
link: string
|
||||
link_target_type: 'blog' | 'github'
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerImageContent = {
|
||||
images: {
|
||||
desktop: string
|
||||
tablet?: string
|
||||
mobile?: string
|
||||
}
|
||||
link: string
|
||||
alt_text?: string
|
||||
activity_id?: string
|
||||
}
|
||||
|
||||
export type BannerEvent = BannerBase & {
|
||||
style_type: 'event'
|
||||
content: BannerImageContent
|
||||
}
|
||||
|
||||
export type BannerAd = BannerBase & {
|
||||
style_type: 'ad'
|
||||
content: BannerImageContent & {
|
||||
partner_id?: string
|
||||
campaign_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd
|
||||
|
||||
const bannerListContract = base
|
||||
.route({
|
||||
path: '/banners',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query: {
|
||||
page: 'plugins' | 'templates'
|
||||
language: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<unknown>())
|
||||
|
||||
const collectionsContract = base
|
||||
.route({
|
||||
path: '/collections',
|
||||
@ -212,6 +419,58 @@ const templateDetailContract = base
|
||||
)
|
||||
.output(type<TemplateDetailResponse>())
|
||||
|
||||
const templateCollectionsContract = base
|
||||
.route({
|
||||
path: '/template-collections',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollectionsResponse>())
|
||||
|
||||
const templateCollectionTemplatesContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
body?: {
|
||||
limit?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollectionTemplatesResponse>())
|
||||
|
||||
const templateSearchContract = base
|
||||
.route({
|
||||
path: '/templates/search/advanced',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: {
|
||||
page: number
|
||||
page_size: number
|
||||
query: string
|
||||
sort_by: string
|
||||
sort_order: string
|
||||
categories?: string[]
|
||||
languages?: string[]
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateSearchResponse>())
|
||||
|
||||
const downloadPluginContract = base
|
||||
.route({
|
||||
path: '/plugins/{organization}/{pluginName}/{version}/download',
|
||||
@ -228,12 +487,90 @@ const downloadPluginContract = base
|
||||
)
|
||||
.output(type<DownloadPluginResponse>())
|
||||
|
||||
const creatorDetailContract = base
|
||||
.route({
|
||||
path: '/creators/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<CreatorDetailResponse>())
|
||||
|
||||
const organizationDetailContract = base
|
||||
.route({
|
||||
path: '/organizations/{id}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<OrganizationDetailResponse>())
|
||||
|
||||
const publisherPluginsContract = base
|
||||
.route({
|
||||
path: '/plugins/publisher/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
query: {
|
||||
page: number
|
||||
page_size: number
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<PublisherPluginsResponse>())
|
||||
|
||||
const publisherTemplatesContract = base
|
||||
.route({
|
||||
path: '/templates/publisher/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
query: {
|
||||
page: number
|
||||
page_size: number
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<PublisherTemplatesResponse>())
|
||||
|
||||
export const marketplaceRouterContract = {
|
||||
banners: {
|
||||
list: bannerListContract,
|
||||
},
|
||||
collections: collectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
templateCollections: templateCollectionsContract,
|
||||
templateCollectionTemplates: templateCollectionTemplatesContract,
|
||||
templateDetail: templateDetailContract,
|
||||
templateSearch: templateSearchContract,
|
||||
downloadPlugin: downloadPluginContract,
|
||||
creatorDetail: creatorDetailContract,
|
||||
organizationDetail: organizationDetailContract,
|
||||
publisherPlugins: publisherPluginsContract,
|
||||
publisherTemplates: publisherTemplatesContract,
|
||||
}
|
||||
|
||||
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
|
||||
|
||||
@ -363,7 +363,9 @@ export const FormDialog: Story = {
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Configure API extension' }))
|
||||
|
||||
await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus()
|
||||
await waitFor(async () => {
|
||||
await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="white"/>
|
||||
<path d="M4.5 7.2v9.3c0 .66.54 1.2 1.2 1.2h1.35V10.2L12 13.95l4.95-3.75v7.5h1.35c.66 0 1.2-.54 1.2-1.2V7.2c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 7.2Z" fill="#EA4335"/>
|
||||
<path d="M5.7 17.7h1.35V10.2L4.5 8.4v8.1c0 .66.54 1.2 1.2 1.2Z" fill="#34A853"/>
|
||||
<path d="M18.3 17.7h-1.35V10.2l2.55-1.8v8.1c0 .66-.54 1.2-1.2 1.2Z" fill="#4285F4"/>
|
||||
<path d="M19.5 7.2v-.75c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 6.45V8.4L12 13.95 19.5 8.4V7.2Z" fill="#C5221F"/>
|
||||
<path d="M4.5 8.4 12 13.95 19.5 8.4" stroke="#EA4335" stroke-width="1.1" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"lastModified": 1785332090,
|
||||
"lastModified": 1786856617,
|
||||
"icons": {
|
||||
"agent-building-blocks": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
|
||||
@ -71,7 +71,8 @@
|
||||
"height": 24
|
||||
},
|
||||
"common-d": {
|
||||
"body": "<g fill=\"none\"><path fill=\"#fff\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID0)\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID1)\" d=\"M7.943 8h.265v7h-.265z\"/><defs><radialGradient id=\"svgID0\" cx=\"0\" cy=\"0\" r=\"1\" gradientTransform=\"matrix(0 8.75 -8.75 0 7.943 8)\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#001FC2\"/><stop offset=\".711\" stop-color=\"#0667F8\" stop-opacity=\".2\"/><stop offset=\"1\" stop-color=\"#155EEF\" stop-opacity=\"0\"/></radialGradient><linearGradient id=\"svgID1\" x1=\"8.062\" x2=\"7.937\" y1=\"8.438\" y2=\"9.203\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></g>"
|
||||
"body": "<g fill=\"none\"><path fill=\"#fff\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID0)\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID1)\" d=\"M7.943 8h.265v7h-.265z\"/><defs><radialGradient id=\"svgID0\" cx=\"0\" cy=\"0\" r=\"1\" gradientTransform=\"matrix(0 8.75 -8.75 0 7.943 8)\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#001FC2\"/><stop offset=\".711\" stop-color=\"#0667F8\" stop-opacity=\".2\"/><stop offset=\"1\" stop-color=\"#155EEF\" stop-opacity=\"0\"/></radialGradient><linearGradient id=\"svgID1\" x1=\"8.062\" x2=\"7.937\" y1=\"8.438\" y2=\"9.203\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></g>",
|
||||
"height": 16
|
||||
},
|
||||
"common-diagonal-dividing-line": {
|
||||
"body": "<path fill=\"none\" stroke=\"#EAECF0\" stroke-linecap=\"round\" d=\"M1 19.354L5.942.646\"/>",
|
||||
@ -89,7 +90,8 @@
|
||||
"height": 24
|
||||
},
|
||||
"common-enter-key": {
|
||||
"body": "<g fill=\"#fff\"><path fill-opacity=\".12\" d=\"M0 4a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4z\"/><path d=\"M3.428 8.736V7.628h7.448q.486 0 .887-.239a1.78 1.78 0 0 0 .873-1.525q0-.486-.238-.882a1.8 1.8 0 0 0-.64-.64a1.7 1.7 0 0 0-.882-.238H10.4V3h.477q.793 0 1.44.388q.65.387 1.036 1.035q.388.648.388 1.44q0 .593-.226 1.113a2.92 2.92 0 0 1-1.525 1.538a2.8 2.8 0 0 1-1.113.222zm2.74 3.32L2.294 8.181l3.874-3.874l.762.763l-3.115 3.11l3.115 3.112z\"/></g>"
|
||||
"body": "<g fill=\"#fff\"><path fill-opacity=\".12\" d=\"M0 4a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4z\"/><path d=\"M3.428 8.736V7.628h7.448q.486 0 .887-.239a1.78 1.78 0 0 0 .873-1.525q0-.486-.238-.882a1.8 1.8 0 0 0-.64-.64a1.7 1.7 0 0 0-.882-.238H10.4V3h.477q.793 0 1.44.388q.65.387 1.036 1.035q.388.648.388 1.44q0 .593-.226 1.113a2.92 2.92 0 0 1-1.525 1.538a2.8 2.8 0 0 1-1.113.222zm2.74 3.32L2.294 8.181l3.874-3.874l.762.763l-3.115 3.11l3.115 3.112z\"/></g>",
|
||||
"height": 16
|
||||
},
|
||||
"common-firecrawl": {
|
||||
"body": "<path fill=\"#FA5D19\" d=\"M23.36 12.83c-1.55.46-2.71 1.5-3.57 2.62c-.18.25-.56.07-.49-.23c1.64-6.73-.52-12.31-7.26-15.07c-.34-.14-.7.17-.6.53c3.06 12.3-9.83 11.26-8.2 25.2c.03.24-.24.41-.44.27c-.6-.44-1.29-1.36-1.75-2c-.14-.19-.44-.14-.5.09A14.3 14.3 0 0 0 0 28.12c0 4.9 2.52 9.21 6.33 11.71c.22.14.5-.06.42-.31a7.7 7.7 0 0 1-.31-2.08c0-.44.03-.89.1-1.31c.16-1.06.52-2.06 1.14-2.97c2.11-3.17 6.35-6.24 5.67-10.4c-.04-.26.27-.43.46-.25c2.99 2.72 3.58 6.39 3.09 9.68c-.05.28.31.44.49.21c.46-.57 1.01-1.07 1.62-1.45c.15-.09.35-.02.41.15c.34.98.84 1.9 1.31 2.82c.57 1.11.87 2.37.82 3.71a7.7 7.7 0 0 1-.31 1.88c-.08.26.2.47.42.32A14 14 0 0 0 28 28.12c0-1.71-.3-3.38-.86-4.94c-1.19-3.29-4.19-5.75-3.43-10.03c.04-.2-.15-.38-.35-.32\"/>",
|
||||
@ -106,6 +108,11 @@
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"common-gmail": {
|
||||
"body": "<g fill=\"none\"><rect width=\"24\" height=\"24\" fill=\"#fff\" rx=\"6\"/><path fill=\"#EA4335\" d=\"M4.5 7.2v9.3c0 .66.54 1.2 1.2 1.2h1.35v-7.5L12 13.95l4.95-3.75v7.5h1.35c.66 0 1.2-.54 1.2-1.2V7.2c0-1.5-1.7-2.35-2.9-1.45L12 9.15l-4.6-3.4c-1.2-.9-2.9-.05-2.9 1.45\"/><path fill=\"#34A853\" d=\"M5.7 17.7h1.35v-7.5L4.5 8.4v8.1c0 .66.54 1.2 1.2 1.2\"/><path fill=\"#4285F4\" d=\"M18.3 17.7h-1.35v-7.5l2.55-1.8v8.1c0 .66-.54 1.2-1.2 1.2\"/><path fill=\"#C5221F\" d=\"M19.5 7.2v-.75c0-1.5-1.7-2.35-2.9-1.45L12 9.15l-4.6-3.4c-1.2-.9-2.9-.05-2.9.7V8.4l7.5 5.55l7.5-5.55z\"/><path stroke=\"#EA4335\" stroke-linejoin=\"round\" stroke-width=\"1.1\" d=\"m4.5 8.4l7.5 5.55l7.5-5.55\"/></g>",
|
||||
"width": 24,
|
||||
"height": 24
|
||||
},
|
||||
"common-google-drive": {
|
||||
"body": "<g fill=\"none\"><path fill=\"#0F9D58\" d=\"M8.2 3h5.1l7.6 13.2h-5.1z\"/><path fill=\"#F4B400\" d=\"M8.2 3L.7 16.2l2.6 4.5l7.5-13.2z\"/><path fill=\"#4285F4\" d=\"M3.3 20.7h15.2l2.4-4.5h-15z\"/></g>",
|
||||
"width": 24,
|
||||
@ -127,10 +134,12 @@
|
||||
"height": 12
|
||||
},
|
||||
"common-lock": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8 1.75a3.125 3.125 0 0 0-3.125 3.125v1.25C3.839 6.125 3 6.965 3 8v4.375c0 1.036.84 1.875 1.875 1.875h6.25c1.036 0 1.875-.84 1.875-1.875V8c0-1.036-.84-1.875-1.875-1.875v-1.25c0-1.726-1.4-3.125-3.125-3.125m1.875 4.375v-1.25a1.875 1.875 0 1 0-3.75 0v1.25zM8 8.625c.345 0 .625.28.625.625v1.875a.625.625 0 1 1-1.25 0V9.25c0-.345.28-.625.625-.625\" clip-rule=\"evenodd\"/>"
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8 1.75a3.125 3.125 0 0 0-3.125 3.125v1.25C3.839 6.125 3 6.965 3 8v4.375c0 1.036.84 1.875 1.875 1.875h6.25c1.036 0 1.875-.84 1.875-1.875V8c0-1.036-.84-1.875-1.875-1.875v-1.25c0-1.726-1.4-3.125-3.125-3.125m1.875 4.375v-1.25a1.875 1.875 0 1 0-3.75 0v1.25zM8 8.625c.345 0 .625.28.625.625v1.875a.625.625 0 1 1-1.25 0V9.25c0-.345.28-.625.625-.625\" clip-rule=\"evenodd\"/>",
|
||||
"height": 16
|
||||
},
|
||||
"common-message-chat-square": {
|
||||
"body": "<g fill=\"#444CE7\"><path fill-rule=\"evenodd\" d=\"M8.774 6.667h3.785c.352 0 .655 0 .904.02c.264.021.526.069.778.197a2 2 0 0 1 .874.875c.129.252.176.514.198.777c.02.25.02.553.02.905v1.856c0 .293 0 .545-.014.754c-.015.22-.048.44-.138.657A2 2 0 0 1 14.1 13.79c-.217.09-.437.124-.657.139l-.109.005v.732a.667.667 0 0 1-1.047.548l-1.45-1.009c-.224-.155-.27-.184-.312-.203a.7.7 0 0 0-.154-.048c-.046-.009-.1-.011-.372-.011H8.774c-.351 0-.654 0-.904-.02a2 2 0 0 1-.778-.198a2 2 0 0 1-.874-.874a2 2 0 0 1-.198-.778C6 11.823 6 11.52 6 11.168V9.441c0-.352 0-.655.02-.905c.022-.263.07-.525.198-.777a2 2 0 0 1 .874-.875c.252-.128.515-.176.778-.197c.25-.02.553-.02.904-.02\" clip-rule=\"evenodd\"/><path d=\"M9.494.667H4.506c-.537 0-.98 0-1.34.029c-.375.03-.72.096-1.043.261A2.67 2.67 0 0 0 .957 2.123c-.164.323-.23.668-.26 1.042c-.03.361-.03.804-.03 1.34V7.68c0 .295 0 .513.028.706a2.67 2.67 0 0 0 2.252 2.252a.2.2 0 0 1 .09.036v1.052c0 .181 0 .36.013.503c.011.128.04.391.228.61a1 1 0 0 0 .842.345c.287-.023.493-.19.59-.273l.087-.077a4 4 0 0 1-.105-.653c-.025-.305-.025-.659-.025-.984V9.413c0-.326 0-.68.025-.985c.028-.346.098-.803.338-1.275c.32-.627.83-1.137 1.457-1.456c.471-.24.928-.31 1.274-.339c.306-.025.66-.025.985-.025h3.841c.244 0 .503 0 .746.01v-.837c0-.537 0-.98-.029-1.34c-.03-.375-.096-.72-.261-1.043A2.67 2.67 0 0 0 11.877.957c-.323-.165-.668-.23-1.042-.261c-.361-.03-.804-.03-1.34-.03\"/></g>"
|
||||
"body": "<g fill=\"#444CE7\"><path fill-rule=\"evenodd\" d=\"M8.774 6.667h3.785c.352 0 .655 0 .904.02c.264.021.526.069.778.197a2 2 0 0 1 .874.875c.129.252.176.514.198.777c.02.25.02.553.02.905v1.856c0 .293 0 .545-.014.754c-.015.22-.048.44-.138.657A2 2 0 0 1 14.1 13.79c-.217.09-.437.124-.657.139l-.109.005v.732a.667.667 0 0 1-1.047.548l-1.45-1.009c-.224-.155-.27-.184-.312-.203a.7.7 0 0 0-.154-.048c-.046-.009-.1-.011-.372-.011H8.774c-.351 0-.654 0-.904-.02a2 2 0 0 1-.778-.198a2 2 0 0 1-.874-.874a2 2 0 0 1-.198-.778C6 11.823 6 11.52 6 11.168V9.441c0-.352 0-.655.02-.905c.022-.263.07-.525.198-.777a2 2 0 0 1 .874-.875c.252-.128.515-.176.778-.197c.25-.02.553-.02.904-.02\" clip-rule=\"evenodd\"/><path d=\"M9.494.667H4.506c-.537 0-.98 0-1.34.029c-.375.03-.72.096-1.043.261A2.67 2.67 0 0 0 .957 2.123c-.164.323-.23.668-.26 1.042c-.03.361-.03.804-.03 1.34V7.68c0 .295 0 .513.028.706a2.67 2.67 0 0 0 2.252 2.252a.2.2 0 0 1 .09.036v1.052c0 .181 0 .36.013.503c.011.128.04.391.228.61a1 1 0 0 0 .842.345c.287-.023.493-.19.59-.273l.087-.077a4 4 0 0 1-.105-.653c-.025-.305-.025-.659-.025-.984V9.413c0-.326 0-.68.025-.985c.028-.346.098-.803.338-1.275c.32-.627.83-1.137 1.457-1.456c.471-.24.928-.31 1.274-.339c.306-.025.66-.025.985-.025h3.841c.244 0 .503 0 .746.01v-.837c0-.537 0-.98-.029-1.34c-.03-.375-.096-.72-.261-1.043A2.67 2.67 0 0 0 11.877.957c-.323-.165-.668-.23-1.042-.261c-.361-.03-.804-.03-1.34-.03\"/></g>",
|
||||
"height": 16
|
||||
},
|
||||
"common-multi-path-retrieval": {
|
||||
"body": "<g fill=\"none\"><g clip-path=\"url(#svgID0)\"><rect width=\"36\" height=\"36\" fill=\"#FFF6ED\" rx=\"8\"/><path stroke=\"#FB6514\" stroke-width=\"1.5\" d=\"M22.25 28a3.25 3.25 0 1 1-6.5 0a3.25 3.25 0 0 1 6.5 0Z\" opacity=\".7\"/><path fill=\"#FB6514\" d=\"M19 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8m-4 10a4 4 0 1 0 0-8a4 4 0 0 0 0 8m21 1a5 5 0 1 0 0-10a5 5 0 0 0 0 10\"/><path stroke=\"#FB6514\" stroke-width=\"1.5\" d=\"M0 18h10m10 0h10M.001 15h.85C7.348 15 7.848 8 14 8m9.75 1.281c2.819.904 3.949 3.923 6.875 5.75\"/><path stroke=\"#FB6514\" stroke-width=\"1.5\" d=\"M0 21h.894C7.365 21 7.869 28 14 28\" opacity=\".7\"/></g><defs><clipPath id=\"svgID0\"><rect width=\"36\" height=\"36\" fill=\"#fff\" rx=\"8\"/></clipPath></defs></g>",
|
||||
@ -158,7 +167,8 @@
|
||||
"height": 14
|
||||
},
|
||||
"common-sparkles-soft-accent": {
|
||||
"body": "<g fill=\"#155AEF\"><path d=\"M12.567 1.563a.253.253 0 0 0-.247-.23a.253.253 0 0 0-.247.23c-.068.61-.241 1.028-.514 1.311c-.272.284-.673.465-1.259.535a.256.256 0 0 0-.22.258c0 .132.095.242.22.257c.576.068.987.25 1.266.535c.278.284.455.701.506 1.305c.012.134.12.236.248.236c.13 0 .237-.103.248-.237c.05-.593.226-1.02.506-1.311s.69-.476 1.259-.527a.255.255 0 0 0 .227-.258a.255.255 0 0 0-.227-.259c-.58-.053-.98-.238-1.253-.527c-.274-.29-.447-.718-.513-1.318\" opacity=\".5\"/><path d=\"M8.156 3.258a.65.65 0 0 0-.636-.591a.65.65 0 0 0-.636.59c-.174 1.567-.62 2.643-1.32 3.372S3.83 7.824 2.325 8.004a.66.66 0 0 0-.566.663c0 .34.244.624.568.662c1.479.175 2.535.64 3.253 1.374c.714.73 1.169 1.804 1.301 3.356a.65.65 0 0 0 .638.608a.65.65 0 0 0 .637-.61c.127-1.525.582-2.623 1.3-3.372c.72-.748 1.773-1.222 3.238-1.354a.657.657 0 0 0 .585-.664a.657.657 0 0 0-.584-.664c-1.49-.138-2.52-.612-3.221-1.356c-.705-.748-1.152-1.848-1.32-3.389\"/></g>"
|
||||
"body": "<g fill=\"#155AEF\"><path d=\"M12.567 1.563a.253.253 0 0 0-.247-.23a.253.253 0 0 0-.247.23c-.068.61-.241 1.028-.514 1.311c-.272.284-.673.465-1.259.535a.256.256 0 0 0-.22.258c0 .132.095.242.22.257c.576.068.987.25 1.266.535c.278.284.455.701.506 1.305c.012.134.12.236.248.236c.13 0 .237-.103.248-.237c.05-.593.226-1.02.506-1.311s.69-.476 1.259-.527a.255.255 0 0 0 .227-.258a.255.255 0 0 0-.227-.259c-.58-.053-.98-.238-1.253-.527c-.274-.29-.447-.718-.513-1.318\" opacity=\".5\"/><path d=\"M8.156 3.258a.65.65 0 0 0-.636-.591a.65.65 0 0 0-.636.59c-.174 1.567-.62 2.643-1.32 3.372S3.83 7.824 2.325 8.004a.66.66 0 0 0-.566.663c0 .34.244.624.568.662c1.479.175 2.535.64 3.253 1.374c.714.73 1.169 1.804 1.301 3.356a.65.65 0 0 0 .638.608a.65.65 0 0 0 .637-.61c.127-1.525.582-2.623 1.3-3.372c.72-.748 1.773-1.222 3.238-1.354a.657.657 0 0 0 .585-.664a.657.657 0 0 0-.584-.664c-1.49-.138-2.52-.612-3.221-1.356c-.705-.748-1.152-1.848-1.32-3.389\"/></g>",
|
||||
"height": 16
|
||||
},
|
||||
"education-triangle": {
|
||||
"body": "<path fill=\"#fff\" d=\"M0 0h16L9.915 16.734A8 8 0 0 1 2.397 22H0z\"/>",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"name": "Dify Custom Public",
|
||||
"total": 150,
|
||||
"total": 151,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -43,24 +43,6 @@
|
||||
"body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 17
|
||||
},
|
||||
"app-publisher-deploying-chevron": {
|
||||
"body": "<g fill=\"none\"><path d=\"M4.13806 0L0 4.13807L0.942807 5.08087L4.13806 1.8856L7.33333 5.08087L8.27613 4.13807L4.13806 0Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 8.27613,
|
||||
"height": 5.08087
|
||||
},
|
||||
"deploy-code-block": {
|
||||
"body": "<g fill=\"none\"><path d=\"M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
"deploy-line-5": {
|
||||
"body": "<g fill=\"none\"><path d=\"M10.9062 0.130859L0.482939 38.4953\" stroke=\"currentColor\" stroke-opacity=\"0.04\"/></g>",
|
||||
"width": 12,
|
||||
"height": 39
|
||||
},
|
||||
"deploy-rocket": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"features-citations": {
|
||||
"body": "<g fill=\"none\"><path d=\"M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM7 11.9702V14.958H11.0356V11.2339H8.8125C8.78418 10.8185 8.85498 10.4173 9.0249 10.0303C9.35531 9.29395 10.002 8.77474 10.9648 8.47266V7C9.67155 7.25488 8.68506 7.79297 8.00537 8.61426C7.33512 9.43555 7 10.5542 7 11.9702ZM15.0391 10.0586C15.3695 9.29395 16.0114 8.7653 16.9648 8.47266V7C15.7093 7.25488 14.7323 7.78825 14.0337 8.6001C13.3446 9.41195 13 10.5353 13 11.9702V14.958H17.0356V11.2339H14.8125C14.7747 10.8563 14.8503 10.4645 15.0391 10.0586Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 24,
|
||||
@ -1610,6 +1592,24 @@
|
||||
"body": "<g fill=\"none\"><path d=\"M1.33325 4.66663C1.33325 3.56206 2.22869 2.66663 3.33325 2.66663H12.6666C13.7712 2.66663 14.6666 3.56206 14.6666 4.66663V8.16663C14.6666 8.53483 14.3681 8.83329 13.9999 8.83329C13.6317 8.83329 13.3333 8.53483 13.3333 8.16663V4.66663C13.3333 4.29844 13.0348 3.99996 12.6666 3.99996H3.33325C2.96507 3.99996 2.66659 4.29844 2.66659 4.66663V12C2.66659 12.3682 2.96507 12.6666 3.33325 12.6666H7.99992C8.36812 12.6666 8.66658 12.9651 8.66658 13.3333C8.66658 13.7015 8.36812 14 7.99992 14H3.33325C2.22869 14 1.33325 13.1046 1.33325 12V4.66663Z\" fill=\"currentColor\"/><path d=\"M3.66659 5.83329C3.66659 6.29353 4.03968 6.66663 4.49992 6.66663C4.96016 6.66663 5.33325 6.29353 5.33325 5.83329C5.33325 5.37305 4.96016 4.99996 4.49992 4.99996C4.03968 4.99996 3.66659 5.37305 3.66659 5.83329Z\" fill=\"currentColor\"/><path d=\"M5.99992 5.83329C5.99992 6.29353 6.37301 6.66663 6.83325 6.66663C7.29352 6.66663 7.66658 6.29353 7.66658 5.83329C7.66658 5.37305 7.29352 4.99996 6.83325 4.99996C6.37301 4.99996 5.99992 5.37305 5.99992 5.83329Z\" fill=\"currentColor\"/><path d=\"M8.33325 5.83329C8.33325 6.29353 8.70632 6.66663 9.16658 6.66663C9.62685 6.66663 9.99992 6.29353 9.99992 5.83329C9.99992 5.37305 9.62685 4.99996 9.16658 4.99996C8.70632 4.99996 8.33325 5.37305 8.33325 5.83329Z\" fill=\"currentColor\"/><path d=\"M10.5293 9.69609C10.2933 9.62349 10.0365 9.68729 9.86185 9.86189C9.68725 10.0365 9.62345 10.2934 9.69605 10.5294L11.0294 14.8627C11.1095 15.1231 11.3401 15.3086 11.6116 15.331C11.8832 15.3535 12.1411 15.2085 12.2629 14.9648L13.1635 13.1636L14.9647 12.263C15.2085 12.1411 15.3535 11.8832 15.331 11.6116C15.3085 11.3401 15.1231 11.1096 14.8627 11.0294L10.5293 9.69609Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"app-publisher-deploying-chevron": {
|
||||
"body": "<g fill=\"none\"><path d=\"M4.13806 0L0 4.13807L0.942807 5.08087L4.13806 1.8856L7.33333 5.08087L8.27613 4.13807L4.13806 0Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 8.27613,
|
||||
"height": 5.08087
|
||||
},
|
||||
"deploy-code-block": {
|
||||
"body": "<g fill=\"none\"><path d=\"M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
"deploy-line-5": {
|
||||
"body": "<g fill=\"none\"><path d=\"M10.9062 0.130859L0.482939 38.4953\" stroke=\"currentColor\" stroke-opacity=\"0.04\"/></g>",
|
||||
"width": 12,
|
||||
"height": 39
|
||||
},
|
||||
"deploy-rocket": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 14,
|
||||
"height": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 346,
|
||||
"total": 350,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -4,6 +4,7 @@ import { canEmbedPath, proxy } from '@/proxy'
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
NEXT_PUBLIC_ALLOW_EMBED: false,
|
||||
NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com',
|
||||
NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: '',
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: '',
|
||||
}))
|
||||
|
||||
@ -24,6 +25,7 @@ const createRequest = (url: string) => {
|
||||
describe('proxy frame options', () => {
|
||||
afterEach(() => {
|
||||
mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false
|
||||
mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = ''
|
||||
mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = ''
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
@ -86,6 +88,36 @@ describe('proxy frame options', () => {
|
||||
expect(response.headers.get('x-frame-options')).toBe('DENY')
|
||||
expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('should deny framing for the Marketplace OAuth authorize route', () => {
|
||||
const response = proxy(
|
||||
createRequest('https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client'),
|
||||
)
|
||||
|
||||
expect(response.headers.get('x-frame-options')).toBe('DENY')
|
||||
expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('should allow framing Marketplace pages when a Marketplace origin is configured', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production')
|
||||
mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai'
|
||||
|
||||
const response = proxy(createRequest('https://cloud.dify.ai/marketplace'))
|
||||
|
||||
expect(response.headers.get('content-security-policy') ?? '').toMatch(
|
||||
/frame-src[^;]*https:\/\/marketplace\.dify\.ai/,
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a Marketplace frame origin when the prefix is unset', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production')
|
||||
|
||||
const response = proxy(createRequest('https://cloud.dify.ai/marketplace'))
|
||||
const contentSecurityPolicy = response.headers.get('content-security-policy') ?? ''
|
||||
|
||||
expect(contentSecurityPolicy).toContain('frame-src')
|
||||
expect(contentSecurityPolicy).not.toContain('https://marketplace.dify.ai')
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxy CookieYes consent logging', () => {
|
||||
|
||||
33
web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx
Normal file
33
web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../document-title', () => ({
|
||||
default: () => <span>marketplace document title</span>,
|
||||
}))
|
||||
|
||||
describe('marketplace route layout', () => {
|
||||
it('stays a server module so Flight can stream the marketplace page', () => {
|
||||
const source = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(source).not.toMatch(/^['"]use client['"]/)
|
||||
})
|
||||
|
||||
it('renders marketplace children and the document title island', async () => {
|
||||
const { default: MarketplaceLayout } = await import('../layout')
|
||||
|
||||
render(
|
||||
<MarketplaceLayout>
|
||||
<p>marketplace page</p>
|
||||
</MarketplaceLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('marketplace document title')).toBeInTheDocument()
|
||||
expect(screen.getByText('marketplace page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
39
web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx
Normal file
39
web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => (
|
||||
<section aria-label="install permission">{children}</section>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/embedded', () => ({
|
||||
EmbeddedMarketplace: () => <p>Embedded marketplace home</p>,
|
||||
}))
|
||||
|
||||
describe('embedded marketplace home route', () => {
|
||||
it('does not stream async server children that Flight would double-resolve', () => {
|
||||
const source = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(source).not.toMatch(/const MarketplacePage = async/)
|
||||
expect(source).not.toContain('HydrateQueryClient')
|
||||
expect(source).not.toContain('AccountSection')
|
||||
expect(source).not.toContain('homeHeaderActions')
|
||||
})
|
||||
|
||||
it('renders the client marketplace home inside the install-permission provider', async () => {
|
||||
const { default: MarketplacePage } = await import('../page')
|
||||
render(<MarketplacePage />)
|
||||
|
||||
const permission = screen.getByRole('region', { name: 'install permission' })
|
||||
|
||||
expect(permission).toContainElement(screen.getByText('Embedded marketplace home'))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
import { loadCreatorProfile } from '@/app/components/plugins/marketplace/creator-profile/data.server'
|
||||
import DifyCreatorProfile from '@/app/components/plugins/marketplace/creator-profile/dify-profile'
|
||||
import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { notFound } from '@/next/navigation'
|
||||
|
||||
type CreatorPageSearchParams = {
|
||||
publisher_type?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}
|
||||
|
||||
type CreatorProfilePageProps = {
|
||||
params: Promise<{ uniqueHandle: string }>
|
||||
searchParams: Promise<CreatorPageSearchParams>
|
||||
}
|
||||
|
||||
// Sync route: async pages under this client shell Flight-double-resolve.
|
||||
export default function CreatorProfilePage(props: CreatorProfilePageProps) {
|
||||
return (
|
||||
<div
|
||||
id="marketplace-container"
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
|
||||
>
|
||||
<CreatorProfileContent {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function CreatorProfileContent({ params, searchParams }: CreatorProfilePageProps) {
|
||||
const [{ uniqueHandle }, query, locale] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getLocaleOnServer(),
|
||||
])
|
||||
const loadedProfile = await loadCreatorProfile({
|
||||
uniqueHandle,
|
||||
publisherType: query.publisher_type,
|
||||
locale,
|
||||
sortBy: query.sort_by,
|
||||
sortOrder: query.sort_order,
|
||||
})
|
||||
|
||||
if (!loadedProfile) notFound()
|
||||
|
||||
return (
|
||||
<MarketplaceInstallPermissionProvider>
|
||||
<DifyCreatorProfile loadedProfile={loadedProfile} locale={locale} />
|
||||
</MarketplaceInstallPermissionProvider>
|
||||
)
|
||||
}
|
||||
12
web/app/(commonLayout)/marketplace/document-title.tsx
Normal file
12
web/app/(commonLayout)/marketplace/document-title.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
const MarketplaceDocumentTitle = () => {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' }))
|
||||
return null
|
||||
}
|
||||
|
||||
export default MarketplaceDocumentTitle
|
||||
@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import MarketplaceDocumentTitle from './document-title'
|
||||
|
||||
// Server layout: a client layout here Flight-double-resolves the page.
|
||||
export default function MarketplaceLayout({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' }))
|
||||
|
||||
return children
|
||||
return (
|
||||
<>
|
||||
<MarketplaceDocumentTitle />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import Marketplace from '@/app/components/plugins/marketplace'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants'
|
||||
import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded'
|
||||
import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider'
|
||||
|
||||
type MarketplacePageProps = {
|
||||
searchParams?: Promise<SearchParams>
|
||||
}
|
||||
|
||||
const MarketplacePage = ({ searchParams }: MarketplacePageProps) => {
|
||||
// Sync route: async pages under this client shell Flight-double-resolve.
|
||||
const MarketplacePage = () => {
|
||||
return (
|
||||
<div
|
||||
id="marketplace-container"
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default-subtle pr-1"
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
|
||||
>
|
||||
<MarketplaceInstallPermissionProvider>
|
||||
<Marketplace searchParams={searchParams} isMarketplacePlatform showInstallButton />
|
||||
<EmbeddedMarketplace showInstallButton variant="home" />
|
||||
</MarketplaceInstallPermissionProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
import type { FunctionComponent, ReactElement } from 'react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import TemplatesPage from '../page'
|
||||
|
||||
type TemplatesPageProps = Parameters<typeof TemplatesPage>[0]
|
||||
|
||||
const resolveTemplatesPage = async (props: TemplatesPageProps) => {
|
||||
const tree = TemplatesPage(props) as ReactElement<{
|
||||
children: ReactElement
|
||||
className: string
|
||||
id: string
|
||||
}>
|
||||
const child = tree.props.children
|
||||
const content = await (child.type as FunctionComponent<typeof child.props>)(child.props)
|
||||
return createElement(tree.type, tree.props, content)
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/templates', () => ({
|
||||
EmbeddedTemplatesMarketplace: ({
|
||||
category,
|
||||
page,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}: {
|
||||
category: string
|
||||
page: number
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
view?: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="catalog"
|
||||
data-page={page}
|
||||
data-sort-by={sortBy}
|
||||
data-sort-order={sortOrder}
|
||||
data-view={view}
|
||||
>
|
||||
{`Templates catalog: ${category}:${query}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: () => Promise.resolve('en-US'),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`redirect:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('embedded templates route', () => {
|
||||
it('does not stream async server children that Flight would double-resolve', () => {
|
||||
const source = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(source).not.toMatch(/export default async function TemplatesPage/)
|
||||
expect(TemplatesPage.constructor.name).not.toBe('AsyncFunction')
|
||||
})
|
||||
|
||||
it('renders the templates catalog at /templates', async () => {
|
||||
const page = await resolveTemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({ q: 'agent' }),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
expect(screen.getByText('Templates catalog: all:agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('Templates catalog: all:agent').parentElement).toHaveAttribute(
|
||||
'id',
|
||||
'marketplace-container',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a supported path category to the templates catalog', async () => {
|
||||
const page = await resolveTemplatesPage({
|
||||
params: Promise.resolve({ category: ['marketing'] }),
|
||||
searchParams: Promise.resolve({}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates page, view and sort params at the route boundary', async () => {
|
||||
const page = await resolveTemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({
|
||||
page: '3',
|
||||
q: 'agent',
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'ASC',
|
||||
view: 'search',
|
||||
}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
const catalog = screen.getByTestId('catalog')
|
||||
expect(catalog).toHaveAttribute('data-page', '3')
|
||||
expect(catalog).toHaveAttribute('data-sort-by', 'created_at')
|
||||
expect(catalog).toHaveAttribute('data-sort-order', 'ASC')
|
||||
expect(catalog).toHaveAttribute('data-view', 'search')
|
||||
})
|
||||
|
||||
it('falls back to defaults for unsupported page, view and sort params', async () => {
|
||||
const page = await resolveTemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({
|
||||
page: '-2',
|
||||
q: 'agent',
|
||||
sort_by: 'garbage',
|
||||
sort_order: 'sideways',
|
||||
view: 'iframe',
|
||||
}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
const catalog = screen.getByTestId('catalog')
|
||||
expect(catalog).toHaveAttribute('data-page', '1')
|
||||
expect(catalog).not.toHaveAttribute('data-sort-by')
|
||||
expect(catalog).not.toHaveAttribute('data-sort-order')
|
||||
expect(catalog).not.toHaveAttribute('data-view')
|
||||
})
|
||||
|
||||
it('opens template recommendations in the existing Dify import flow', async () => {
|
||||
await expect(
|
||||
resolveTemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({ tid: 'template/one' }),
|
||||
}),
|
||||
).rejects.toThrow('redirect:/apps?template-id=template%2Fone')
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith('/apps?template-id=template%2Fone')
|
||||
})
|
||||
})
|
||||
78
web/app/(commonLayout)/templates/[[...category]]/page.tsx
Normal file
78
web/app/(commonLayout)/templates/[[...category]]/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants'
|
||||
import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates'
|
||||
import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
type TemplatesPageProps = {
|
||||
params: Promise<{ category?: string[] }>
|
||||
searchParams: Promise<{
|
||||
languages?: string | string[]
|
||||
page?: string
|
||||
q?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
tid?: string
|
||||
view?: string
|
||||
}>
|
||||
}
|
||||
|
||||
// These values arrive from a public URL, so validate them against the
|
||||
// supported enums here at the route boundary. Unknown values fall back to the
|
||||
// defaults instead of reaching the Marketplace API, where e.g.
|
||||
// `sort_order=garbage` fails and would surface as a false "no templates" state.
|
||||
const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at'])
|
||||
const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC'])
|
||||
|
||||
const parseView = (value?: string) => (value === 'search' ? 'search' : undefined)
|
||||
|
||||
const parseSortBy = (value?: string) =>
|
||||
value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined
|
||||
|
||||
const parseSortOrder = (value?: string) =>
|
||||
value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined
|
||||
|
||||
const parsePage = (value?: string) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1
|
||||
}
|
||||
|
||||
// Sync route: async pages under this client shell Flight-double-resolve.
|
||||
export default function TemplatesPage(props: TemplatesPageProps) {
|
||||
return (
|
||||
<div
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
|
||||
>
|
||||
<TemplatesPageContent {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function TemplatesPageContent({ params, searchParams }: TemplatesPageProps) {
|
||||
const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getLocaleOnServer(),
|
||||
])
|
||||
|
||||
if (resolvedSearchParams.tid) {
|
||||
redirect(`/apps?template-id=${encodeURIComponent(resolvedSearchParams.tid)}`)
|
||||
}
|
||||
|
||||
const requestedCategory = resolvedParams.category?.[0]
|
||||
const category = isTemplateCategory(requestedCategory) ? requestedCategory : 'all'
|
||||
|
||||
return (
|
||||
<EmbeddedTemplatesMarketplace
|
||||
category={category}
|
||||
languages={resolvedSearchParams.languages}
|
||||
locale={locale}
|
||||
page={parsePage(resolvedSearchParams.page)}
|
||||
query={resolvedSearchParams.q ?? ''}
|
||||
sortBy={parseSortBy(resolvedSearchParams.sort_by)}
|
||||
sortOrder={parseSortOrder(resolvedSearchParams.sort_order)}
|
||||
view={parseView(resolvedSearchParams.view)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -127,6 +127,24 @@ describe('OAuthAuthorize', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an encoded redirect URI when requesting the OAuth app', async () => {
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'client-1',
|
||||
redirect_uri: 'https://client.example.com/callback?next=%2Fplugins',
|
||||
state: 'state-1',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0)
|
||||
const providerRequest = findRequest('/oauth/provider')
|
||||
const providerTransportRequest = providerRequest?.[2]?.request as Request
|
||||
await expect(providerTransportRequest.clone().json()).resolves.toEqual({
|
||||
client_id: 'client-1',
|
||||
redirect_uri: 'https://client.example.com/callback?next=%2Fplugins',
|
||||
})
|
||||
})
|
||||
|
||||
it('silently authorizes an app flagged with auto_authorize without rendering consent', async () => {
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'marketplace-client',
|
||||
@ -177,6 +195,56 @@ describe('OAuthAuthorize', () => {
|
||||
expect(findRequest('/oauth/provider/authorize')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not auto-authorize with incomplete OAuth parameters', async () => {
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'marketplace-client',
|
||||
})
|
||||
mockProviderResponses({ autoAuthorize: true })
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('oauth.error.invalidParams')).toBeInTheDocument()
|
||||
expect(findRequest('/oauth/provider')).toBeUndefined()
|
||||
expect(findRequest('/oauth/provider/authorize')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retries app info loading and resumes auto-authorization', async () => {
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'marketplace-client',
|
||||
redirect_uri: 'https://api.marketplace.example.com/api/v1/auth/callback/dify',
|
||||
response_type: 'code',
|
||||
state: 'marketplace-state',
|
||||
})
|
||||
let providerAttempts = 0
|
||||
mocks.request.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' })
|
||||
if (url.endsWith('/oauth/provider')) {
|
||||
providerAttempts += 1
|
||||
if (providerAttempts === 1) throw new Error('Failed to load OAuth app')
|
||||
return jsonResponse({
|
||||
app_icon: '',
|
||||
app_label: { en_US: 'Test OAuth App' },
|
||||
auto_authorize: true,
|
||||
scope: '',
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('oauth.error.authAppInfoFetchFailed')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined())
|
||||
await waitFor(() =>
|
||||
expect(globalThis.location.href).toBe(
|
||||
'https://api.marketplace.example.com/api/v1/auth/callback/dify?code=oauth-code&state=marketplace-state',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to manual confirmation when silent authorization fails', async () => {
|
||||
mocks.searchParams = new URLSearchParams({
|
||||
client_id: 'marketplace-client',
|
||||
@ -215,4 +283,38 @@ describe('OAuthAuthorize', () => {
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders an unknown OAuth scope without crashing', async () => {
|
||||
mocks.request.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/oauth/provider')) {
|
||||
return jsonResponse({
|
||||
app_icon: '',
|
||||
app_label: { en_US: 'Test OAuth App' },
|
||||
scope: 'read:custom_profile',
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('read:custom_profile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports OAuth app labels that use a hyphenated locale key', async () => {
|
||||
mocks.request.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/oauth/provider')) {
|
||||
return jsonResponse({
|
||||
app_icon: '',
|
||||
app_label: { 'en-US': 'Hyphenated OAuth App' },
|
||||
scope: '',
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect((await screen.findAllByText('Hyphenated OAuth App')).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -57,10 +56,10 @@ export default function OAuthAuthorize() {
|
||||
const router = useRouter()
|
||||
const language = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
|
||||
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
|
||||
const clientId = searchParams.get('client_id') || ''
|
||||
const redirectUri = searchParams.get('redirect_uri') || ''
|
||||
const state = searchParams.get('state')
|
||||
const hasOAuthParams = Boolean(client_id && redirect_uri)
|
||||
const hasOAuthParams = Boolean(clientId && redirectUri)
|
||||
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
|
||||
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
|
||||
// service/base.ts prevents a redirect loop here.
|
||||
@ -77,10 +76,14 @@ export default function OAuthAuthorize() {
|
||||
const {
|
||||
data: authAppInfo,
|
||||
isLoading: isOAuthLoading,
|
||||
isError,
|
||||
isFetching: isOAuthFetching,
|
||||
isError: isOAuthError,
|
||||
refetch: refetchOAuthApp,
|
||||
} = useQuery(
|
||||
consoleQuery.oauth.provider.post.queryOptions({
|
||||
input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken,
|
||||
input: hasOAuthParams
|
||||
? { body: { client_id: clientId, redirect_uri: redirectUri } }
|
||||
: skipToken,
|
||||
context: { silent: true },
|
||||
}),
|
||||
)
|
||||
@ -91,17 +94,17 @@ export default function OAuthAuthorize() {
|
||||
const { isAutoAuthorizing } = useSilentAuthorize({
|
||||
authAppInfo,
|
||||
authorize,
|
||||
clientId: client_id,
|
||||
clientId,
|
||||
hasOAuthParams,
|
||||
isLoggedIn,
|
||||
isProfileLoading,
|
||||
redirectUri: redirect_uri,
|
||||
redirectUri,
|
||||
searchParams,
|
||||
state,
|
||||
})
|
||||
const hasNotifiedRef = useRef(false)
|
||||
const localizedAppLabel = authAppInfo?.app_label[language]
|
||||
const englishAppLabel = authAppInfo?.app_label.en_US
|
||||
const localizedAppLabel =
|
||||
authAppInfo?.app_label[language] ?? authAppInfo?.app_label[language.replace('_', '-')]
|
||||
const englishAppLabel = authAppInfo?.app_label.en_US ?? authAppInfo?.app_label['en-US']
|
||||
const appLabel =
|
||||
(typeof localizedAppLabel === 'string' && localizedAppLabel) ||
|
||||
(typeof englishAppLabel === 'string' && englishAppLabel) ||
|
||||
@ -112,7 +115,6 @@ export default function OAuthAuthorize() {
|
||||
: t(($) => $.connect, { ns: 'oauth' }),
|
||||
)
|
||||
|
||||
const isLoading = isOAuthLoading || isProfileLoading
|
||||
const onLoginSwitchClick = async () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
|
||||
@ -124,30 +126,39 @@ export default function OAuthAuthorize() {
|
||||
}
|
||||
|
||||
const onAuthorize = async () => {
|
||||
if (!client_id || !redirect_uri) return
|
||||
if (!clientId || !redirectUri) return
|
||||
try {
|
||||
const { code } = await authorize({ body: { client_id } })
|
||||
globalThis.location.href = buildOAuthCallbackUrl(redirect_uri, code, state)
|
||||
const { code } = await authorize({ body: { client_id: clientId } })
|
||||
globalThis.location.href = buildOAuthCallbackUrl(redirectUri, code, state)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const invalidParams = !client_id || !redirect_uri
|
||||
if ((invalidParams || isError) && !hasNotifiedRef.current) {
|
||||
hasNotifiedRef.current = true
|
||||
toast.error(
|
||||
invalidParams
|
||||
? t(($) => $['error.invalidParams'], { ns: 'oauth' })
|
||||
: t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }),
|
||||
{ timeout: 0 },
|
||||
)
|
||||
}
|
||||
}, [client_id, redirect_uri, isError])
|
||||
if (!hasOAuthParams || isOAuthError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 bg-background-default-subtle text-text-secondary">
|
||||
<div className="body-md-regular">
|
||||
{t(($) => $[hasOAuthParams ? 'error.authAppInfoFetchFailed' : 'error.invalidParams'], {
|
||||
ns: 'oauth',
|
||||
})}
|
||||
</div>
|
||||
{isOAuthError && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
onClick={() => void refetchOAuthApp()}
|
||||
loading={isOAuthFetching}
|
||||
>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || isAutoAuthorizing) {
|
||||
if (isProfileLoading || isOAuthLoading || isAutoAuthorizing) {
|
||||
return (
|
||||
<div className="bg-background-default-subtle">
|
||||
<Loading type="app" />
|
||||
@ -203,18 +214,15 @@ export default function OAuthAuthorize() {
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((scope: string) => {
|
||||
const Icon = SCOPE_INFO_MAP[scope]
|
||||
const scopeInfo = SCOPE_INFO_MAP[scope]
|
||||
const ScopeIcon = scopeInfo?.icon ?? RiAccountCircleLine
|
||||
return (
|
||||
<div
|
||||
key={scope}
|
||||
className="flex items-center gap-2 body-sm-medium text-text-secondary"
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon.icon className="size-4" />
|
||||
) : (
|
||||
<RiAccountCircleLine className="size-4" />
|
||||
)}
|
||||
{Icon!.label}
|
||||
<ScopeIcon className="size-4" />
|
||||
{scopeInfo?.label ?? scope}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -238,7 +246,7 @@ export default function OAuthAuthorize() {
|
||||
size="large"
|
||||
className="w-full"
|
||||
onClick={onAuthorize}
|
||||
disabled={!client_id || !redirect_uri || isError || authorizing}
|
||||
disabled={!clientId || !redirectUri || isOAuthError || authorizing}
|
||||
loading={authorizing}
|
||||
>
|
||||
{t(($) => $.continue, { ns: 'oauth' })}
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
{
|
||||
"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": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M7.1499 6.35213L7.25146 6.38208L14.2248 9.03898L14.3172 9.08195C14.7224 9.30788 14.778 9.87906 14.424 10.179L14.342 10.2389L11.8172 11.817L10.2391 14.3417C9.96271 14.7839 9.32424 14.751 9.08219 14.317L9.03923 14.2245L6.38232 7.25122C6.18829 6.74188 6.64437 6.24196 7.1499 6.35213ZM9.81201 12.5084L10.7671 10.981L10.8114 10.9185C10.8589 10.8589 10.9163 10.8075 10.9813 10.7668L12.5086 9.81177L8.15251 8.15226L9.81201 12.5084Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M5.2124 10.3977L3.56266 12.0474L2.61995 11.1047L4.26969 9.455L5.2124 10.3977Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.66683 7.99992H1.3335V6.66659H3.66683V7.99992Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M5.2124 4.2688L4.26969 5.21151L2.61995 3.56177L3.56266 2.61906L5.2124 4.2688Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M12.0477 3.56177L10.3979 5.21151L9.45524 4.2688L11.105 2.61906L12.0477 3.56177Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M8.00016 3.66659H6.66683V1.33325H8.00016V3.66659Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Trigger"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
// 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 './Trigger.json'
|
||||
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
}) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Trigger'
|
||||
|
||||
export default Icon
|
||||
@ -1,3 +1,2 @@
|
||||
export { default as BoxSparkleFill } from './BoxSparkleFill'
|
||||
export { default as LeftCorner } from './LeftCorner'
|
||||
export { default as Trigger } from './Trigger'
|
||||
|
||||
@ -25,7 +25,7 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
describe('useMarketplaceAllPlugins', () => {
|
||||
const mockQueryPlugins = vi.fn()
|
||||
const mockQueryPluginsWithDebounced = vi.fn()
|
||||
const mockResetPlugins = vi.fn()
|
||||
const mockResetQueryParams = vi.fn()
|
||||
const mockCancelQueryPluginsWithDebounced = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
@ -35,7 +35,7 @@ describe('useMarketplaceAllPlugins', () => {
|
||||
({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: mockResetPlugins,
|
||||
resetQueryParams: mockResetQueryParams,
|
||||
queryPlugins: mockQueryPlugins,
|
||||
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced,
|
||||
|
||||
@ -268,12 +268,14 @@ export const useMarketplaceAllPlugins = (
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced = () => {},
|
||||
resetQueryParams = () => {},
|
||||
isLoading: isPluginsLoading,
|
||||
} = useMarketplacePlugins(enabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
cancelQueryPluginsWithDebounced()
|
||||
resetQueryParams()
|
||||
return
|
||||
}
|
||||
|
||||
@ -302,6 +304,7 @@ export const useMarketplaceAllPlugins = (
|
||||
enabled,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
resetQueryParams,
|
||||
searchText,
|
||||
exclude,
|
||||
])
|
||||
|
||||
@ -979,14 +979,17 @@ describe('MainNav', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('marks marketplace active on marketplace routes', () => {
|
||||
mockPathname = '/marketplace'
|
||||
it.each(['/marketplace', '/plugins', '/templates', '/templates/marketing'])(
|
||||
'marks marketplace active on route %s',
|
||||
(pathname) => {
|
||||
mockPathname = pathname
|
||||
|
||||
renderMainNav()
|
||||
renderMainNav()
|
||||
|
||||
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
|
||||
expect(marketplaceLink).toHaveClass(activeGradientMaskClassName)
|
||||
})
|
||||
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
|
||||
expect(marketplaceLink).toHaveClass(activeGradientMaskClassName)
|
||||
},
|
||||
)
|
||||
|
||||
it('marks roster active on roster routes', () => {
|
||||
mockPathname = '/agents'
|
||||
@ -1184,7 +1187,8 @@ describe('MainNav', () => {
|
||||
'common.mainNav.help.learnDify',
|
||||
'common.mainNav.help.stepByStepTour',
|
||||
'common.userProfile.compliance',
|
||||
'Discord',
|
||||
'common.userProfile.discord',
|
||||
'common.mainNav.help.creatorCenter',
|
||||
'common.userProfile.github',
|
||||
'common.userProfile.about',
|
||||
]
|
||||
@ -1195,6 +1199,23 @@ describe('MainNav', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens Creator Center from the help menu above GitHub', async () => {
|
||||
renderMainNav()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
|
||||
|
||||
const creatorCenter = await screen.findByRole('menuitem', {
|
||||
name: 'common.mainNav.help.creatorCenter',
|
||||
})
|
||||
const github = screen.getByRole('menuitem', { name: /common\.userProfile\.github/ })
|
||||
|
||||
expect(creatorCenter).toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
expect(creatorCenter).toHaveAttribute('target', '_blank')
|
||||
expect(creatorCenter).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(creatorCenter.compareDocumentPosition(github)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(creatorCenter.querySelector('.i-ri-user-star-line')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens About from its real Help menu owner and restores focus when closed', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockConsoleState.current = {
|
||||
@ -1267,7 +1288,7 @@ describe('MainNav', () => {
|
||||
fireEvent.click(contactUsItem)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Discord')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.discord')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
@ -10,6 +11,7 @@ import MainNavLayout from '../layout'
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
current: {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
}))
|
||||
|
||||
@ -22,6 +24,14 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
|
||||
<div data-testid="header-wrapper">{children}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
|
||||
@ -55,7 +65,13 @@ describe('MainNavLayout', () => {
|
||||
;(usePathname as Mock).mockReturnValue('/apps')
|
||||
mockConsoleState.current = {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}
|
||||
;(useSuspenseQuery as Mock).mockReturnValue({
|
||||
data: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
})
|
||||
;(isAgentV2Enabled as Mock).mockReturnValue(true)
|
||||
})
|
||||
|
||||
@ -205,29 +221,64 @@ describe('MainNavLayout', () => {
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['/datasets/create', '/datasets/new/create', '/datasets/dataset-1/documents/create'])(
|
||||
'keeps the global main nav on collection and creation route %s',
|
||||
(pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
it.each([
|
||||
'/datasets/create',
|
||||
'/datasets/new/create',
|
||||
'/datasets/dataset-1/documents/create',
|
||||
'/deployments/create',
|
||||
])('keeps the global main nav on collection and creation route %s', (pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'agent detail route for dataset operators',
|
||||
pathname: '/agents/agent-1/configure',
|
||||
consoleState: {
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the global main nav on agent detail routes for dataset operators', () => {
|
||||
;(usePathname as Mock).mockReturnValue('/agents/agent-1/configure')
|
||||
mockConsoleState.current = {
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
}
|
||||
{
|
||||
label: 'deployment detail route for non-editor workspaces',
|
||||
pathname: '/deployments/app-instance-1/overview',
|
||||
consoleState: {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'deployment detail route when deployment is disabled',
|
||||
pathname: '/deployments/app-instance-1/overview',
|
||||
consoleState: {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: false,
|
||||
},
|
||||
},
|
||||
])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
mockConsoleState.current = consoleState
|
||||
;(useSuspenseQuery as Mock).mockReturnValue({
|
||||
data: systemFeatures,
|
||||
})
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
|
||||
@ -117,12 +117,18 @@ describe('SupportMenu', () => {
|
||||
renderSupportMenu()
|
||||
|
||||
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen
|
||||
.getByText('common.userProfile.contactUs')
|
||||
.compareDocumentPosition(screen.getByText('Discord')),
|
||||
.compareDocumentPosition(screen.getByText('common.userProfile.discord')),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(screen.getByRole('menuitem', { name: 'common.userProfile.discord' })).toHaveClass(
|
||||
'mx-0',
|
||||
'px-3',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' }))
|
||||
|
||||
@ -177,7 +183,7 @@ describe('SupportMenu', () => {
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => {
|
||||
@ -229,7 +235,7 @@ describe('SupportMenu', () => {
|
||||
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders email support when Zendesk is not configured for a dedicated support channel', () => {
|
||||
@ -245,12 +251,12 @@ describe('SupportMenu', () => {
|
||||
).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
})
|
||||
|
||||
it('has the correct Discord link', () => {
|
||||
it('has the Discord link and no Forum entry', () => {
|
||||
renderSupportMenu()
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Discord' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://discord.gg/5AEfbxcd9k',
|
||||
)
|
||||
const discordLink = screen.getByText('common.userProfile.discord').closest('a')
|
||||
expect(discordLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k')
|
||||
expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
MenuItemContent,
|
||||
} from '@/app/components/header/account-dropdown/menu-item-content'
|
||||
import GithubStar from '@/app/components/header/github-star'
|
||||
import { useCreatorCenterUrl } from '@/app/components/plugins/marketplace/creator-center-url'
|
||||
import { trackStepByStepTourEvent } from '@/app/components/step-by-step-tour/analytics'
|
||||
import {
|
||||
disableStepByStepTourForCurrentWorkspaceAtom,
|
||||
@ -38,6 +39,7 @@ import {
|
||||
stepByStepTourStateUpdatingAtom,
|
||||
} from '@/app/components/step-by-step-tour/state'
|
||||
import { useSetStepByStepTourShellMode } from '@/app/components/step-by-step-tour/storage'
|
||||
import { MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
import { getLangGeniusVersionInfo } from '@/context/app-context-normalizers'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import {
|
||||
@ -91,6 +93,7 @@ const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => (
|
||||
const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: HelpMenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: profileMeta } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
@ -252,6 +255,18 @@ const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: He
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="my-0!" />
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuLinkItem
|
||||
href={creatorCenterUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-0 h-8 gap-1 px-3 py-1.5"
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-user-star-line"
|
||||
label={t(($) => $['mainNav.help.creatorCenter'], { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuLinkItem
|
||||
href="https://github.com/langgenius/dify"
|
||||
target="_blank"
|
||||
|
||||
@ -107,7 +107,7 @@ export default function SupportMenu() {
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discord-line"
|
||||
label="Discord"
|
||||
label={t(($) => $['userProfile.discord'], { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuLinkItem>
|
||||
|
||||
@ -43,7 +43,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
key: 'home',
|
||||
href: '/',
|
||||
labelKey: 'mainNav.home',
|
||||
active: (path: string) => path === '/',
|
||||
active: (path: string) => path === '/' || path === '/explore/apps',
|
||||
icon: 'i-custom-vender-main-nav-home-v2',
|
||||
activeIcon: 'i-custom-vender-main-nav-home-v2-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
@ -103,7 +103,9 @@ export const MAIN_NAV_ROUTES = [
|
||||
href: '/marketplace',
|
||||
labelKey: 'mainNav.marketplace',
|
||||
active: (path: string) =>
|
||||
isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'),
|
||||
isPathUnderRoute(path, '/marketplace') ||
|
||||
isPathUnderRoute(path, '/plugins') ||
|
||||
isPathUnderRoute(path, '/templates'),
|
||||
icon: 'i-custom-vender-main-nav-marketplace-v2',
|
||||
activeIcon: 'i-custom-vender-main-nav-marketplace-v2-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark'
|
||||
import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight'
|
||||
|
||||
108
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal file
108
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import type { CardPayload } from '../index'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import Card from '../index'
|
||||
|
||||
vi.mock('jotai', () => ({
|
||||
useAtomValue: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', () => ({
|
||||
currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string | ((dict: Record<string, string>) => string), options?: { ns?: string }) => {
|
||||
if (typeof key === 'string') return key
|
||||
|
||||
// Independent Marketplace does not load the tools namespace, so
|
||||
// tools.author falls back to the key name "author".
|
||||
const dict: Record<string, string> =
|
||||
options?.ns === 'tools'
|
||||
? { author: 'author' }
|
||||
: {
|
||||
'marketplace.by': 'by',
|
||||
'marketplace.partnerTip': 'Verified by a Dify partner',
|
||||
'marketplace.verifiedTip': 'Verified by Dify',
|
||||
install: '{{num}} installs',
|
||||
}
|
||||
return key(dict)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (value: Record<string, string>) => value['en-US'] ?? '',
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
tool: { label: 'Tool' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const marketplacePlugin = {
|
||||
badges: [],
|
||||
brief: { 'en-US': 'Marketplace plugin description' },
|
||||
category: PluginCategoryEnum.tool,
|
||||
description: { 'en-US': 'Marketplace plugin description' },
|
||||
endpoint: { settings: [] },
|
||||
from: 'marketplace',
|
||||
icon: 'icon.png',
|
||||
install_count: 0,
|
||||
introduction: '',
|
||||
label: { 'en-US': 'Marketplace plugin' },
|
||||
latest_package_identifier: 'langgenius/demo-plugin:1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
name: 'demo-plugin',
|
||||
org: 'langgenius',
|
||||
plugin_id: 'langgenius/demo-plugin',
|
||||
repository: '',
|
||||
tags: [],
|
||||
type: 'plugin',
|
||||
verified: false,
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
version: '1.0.0',
|
||||
} satisfies CardPayload
|
||||
|
||||
describe('Plugin card workspace boundary', () => {
|
||||
it('renders Marketplace variant icons without reading Dify workspace state', () => {
|
||||
vi.mocked(useAtomValue).mockImplementation(() => {
|
||||
throw new Error('Dify workspace state must not be read')
|
||||
})
|
||||
|
||||
const payloadWithoutSource = {
|
||||
...marketplacePlugin,
|
||||
from: undefined,
|
||||
} as unknown as CardPayload
|
||||
const { container } = render(<Card payload={payloadWithoutSource} variant="marketplace" />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute(
|
||||
'src',
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`,
|
||||
)
|
||||
expect(useAtomValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels the marketplace author as by, not the tools.author key fallback', () => {
|
||||
const { container } = render(<Card payload={marketplacePlugin} variant="marketplace" />)
|
||||
|
||||
expect(container).toHaveTextContent('by')
|
||||
expect(container).toHaveTextContent('langgenius')
|
||||
expect(container).not.toHaveTextContent('author')
|
||||
})
|
||||
})
|
||||
@ -42,6 +42,37 @@ type Props = Readonly<{
|
||||
variant?: 'default' | 'marketplace'
|
||||
}>
|
||||
|
||||
type CardIconProps = {
|
||||
icon: CardPayload['icon']
|
||||
installFailed?: boolean
|
||||
installed?: boolean
|
||||
marketplace?: boolean
|
||||
plugin: Pick<Plugin, 'from' | 'name' | 'org' | 'type'>
|
||||
}
|
||||
|
||||
const WorkspaceCardIcon = ({ icon, installFailed, installed, plugin }: CardIconProps) => {
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
const iconSrc = getPluginCardIconUrl(plugin, icon, currentWorkspaceId)
|
||||
|
||||
return <Icon src={iconSrc} installed={installed} installFailed={installFailed} />
|
||||
}
|
||||
|
||||
const CardIcon = ({ icon, installFailed, installed, marketplace, plugin }: CardIconProps) => {
|
||||
if (marketplace || plugin.from === 'marketplace') {
|
||||
const iconSrc = getPluginCardIconUrl({ ...plugin, from: 'marketplace' }, icon, '')
|
||||
return <Icon src={iconSrc} installed={installed} installFailed={installFailed} />
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceCardIcon
|
||||
icon={icon}
|
||||
installFailed={installFailed}
|
||||
installed={installed}
|
||||
plugin={plugin}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
className,
|
||||
payload,
|
||||
@ -60,15 +91,11 @@ const Card = ({
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
|
||||
const badges = payload.badges ?? []
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = getPluginCardIconUrl(
|
||||
{ from, name, org, type },
|
||||
theme === Theme.dark && icon_dark ? icon_dark : icon,
|
||||
currentWorkspaceId,
|
||||
)
|
||||
const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
const pluginIdentity = { from, name, org, type }
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
obj ? renderI18nObject(obj, locale) : ''
|
||||
const isPartner = badges.includes('partner')
|
||||
@ -92,7 +119,13 @@ const Card = ({
|
||||
<div className="relative flex h-full flex-col">
|
||||
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
|
||||
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
|
||||
<CardIcon
|
||||
icon={activeIcon}
|
||||
installed={installed}
|
||||
installFailed={installFailed}
|
||||
marketplace
|
||||
plugin={pluginIdentity}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<div className="flex h-5 min-w-0 items-center">
|
||||
<div className="truncate system-md-medium text-text-primary">
|
||||
@ -116,7 +149,7 @@ const Card = ({
|
||||
{org && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="shrink-0 lowercase">
|
||||
{t(($) => $.author, { ns: 'tools' })}
|
||||
{t(($) => $['marketplace.by'], { ns: 'plugin' })}
|
||||
</span>
|
||||
<span className="truncate">{org}</span>
|
||||
</div>
|
||||
@ -152,7 +185,12 @@ const Card = ({
|
||||
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
|
||||
{/* Header */}
|
||||
<div className="flex">
|
||||
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
|
||||
<CardIcon
|
||||
icon={activeIcon}
|
||||
installed={installed}
|
||||
installFailed={installFailed}
|
||||
plugin={pluginIdentity}
|
||||
/>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={getLocalizedText(label)} />
|
||||
|
||||
@ -6,6 +6,7 @@ import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import {
|
||||
useActivePluginType,
|
||||
useFilterPluginTags,
|
||||
useFilterTemplateLanguages,
|
||||
useMarketplaceMoreClick,
|
||||
useMarketplaceSearchMode,
|
||||
useMarketplaceSort,
|
||||
@ -128,6 +129,25 @@ describe('useFilterPluginTags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFilterTemplateLanguages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty array as default', () => {
|
||||
const { wrapper } = createWrapper()
|
||||
const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper })
|
||||
|
||||
expect(result.current[0]).toEqual([])
|
||||
})
|
||||
|
||||
it('parses languages from search params', () => {
|
||||
const { wrapper } = createWrapper('?languages=ja')
|
||||
const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper })
|
||||
expect(result.current[0]).toEqual(['ja'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMarketplaceSearchMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCreatorCenterUrl,
|
||||
PUBLIC_CREATOR_CENTER_URL,
|
||||
rewriteMarketplaceOriginToCreators,
|
||||
} from '../creator-center-url'
|
||||
|
||||
describe('getCreatorCenterUrl', () => {
|
||||
it('maps the public Marketplace to the public Creator Center', () => {
|
||||
expect(getCreatorCenterUrl('https://marketplace.dify.ai')).toBe('https://creators.dify.ai/')
|
||||
})
|
||||
|
||||
it('maps marketplace.dify.dev to creators.dify.dev', () => {
|
||||
expect(getCreatorCenterUrl('https://marketplace.dify.dev')).toBe('https://creators.dify.dev/')
|
||||
})
|
||||
|
||||
it('keeps the staging suffix on the Creators host', () => {
|
||||
expect(getCreatorCenterUrl('https://marketplace-staging.dify.dev')).toBe(
|
||||
'https://creators-staging.dify.dev/',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the public Creator Center for localhost', () => {
|
||||
expect(getCreatorCenterUrl('http://localhost:3000')).toBe(PUBLIC_CREATOR_CENTER_URL)
|
||||
})
|
||||
|
||||
it('falls back to the public Creator Center when the prefix is empty', () => {
|
||||
expect(getCreatorCenterUrl('')).toBe(PUBLIC_CREATOR_CENTER_URL)
|
||||
})
|
||||
|
||||
it('prefers the current Marketplace page over a stale configured prefix', () => {
|
||||
expect(getCreatorCenterUrl('https://marketplace.dify.ai', 'https://marketplace.dify.dev')).toBe(
|
||||
'https://creators.dify.dev/',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteMarketplaceOriginToCreators', () => {
|
||||
it('returns null for hosts that are not a Marketplace surface', () => {
|
||||
expect(rewriteMarketplaceOriginToCreators('https://cloud.dify.ai')).toBeNull()
|
||||
expect(rewriteMarketplaceOriginToCreators('http://localhost:3000')).toBeNull()
|
||||
expect(rewriteMarketplaceOriginToCreators('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,154 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockFetchPluginBanners = vi.fn()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'zh-Hans',
|
||||
}))
|
||||
|
||||
vi.mock('../home/banners', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../home/banners')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../view', () => ({
|
||||
MarketplaceView: ({
|
||||
banners,
|
||||
showInstallButton,
|
||||
}: {
|
||||
banners: PluginBanner[]
|
||||
showInstallButton: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<p>Trending banners: {banners.length}</p>
|
||||
<p>{showInstallButton ? 'Install enabled' : 'Install disabled'}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
describe('EmbeddedMarketplace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('loads homepage banners on the client for the active locale', async () => {
|
||||
mockFetchPluginBanners.mockResolvedValue([
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'zh-Hans',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[])
|
||||
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(<EmbeddedMarketplace showInstallButton variant="home" />, { wrapper: Wrapper })
|
||||
|
||||
expect(await screen.findByText('Trending banners: 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Install enabled')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans')
|
||||
})
|
||||
|
||||
it('uses server-rendered homepage banners without requesting them again on hydration', async () => {
|
||||
const initialBanners = [
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'zh-Hans',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[]
|
||||
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(
|
||||
<EmbeddedMarketplace
|
||||
initialBanners={initialBanners}
|
||||
initialLocale="zh-Hans"
|
||||
showInstallButton
|
||||
variant="home"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
expect(screen.getByText('Trending banners: 1')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches banners when the client locale differs from the server-rendered locale', async () => {
|
||||
const initialBanners = [
|
||||
{
|
||||
id: 'banner-en',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'en-US',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[]
|
||||
mockFetchPluginBanners.mockResolvedValue([])
|
||||
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(
|
||||
<EmbeddedMarketplace
|
||||
initialBanners={initialBanners}
|
||||
initialLocale="en-US"
|
||||
showInstallButton
|
||||
variant="home"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Trending banners: 0')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans')
|
||||
})
|
||||
|
||||
it('does not request homepage banners for the default catalog variant', async () => {
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(<EmbeddedMarketplace variant="default" />, { wrapper: Wrapper })
|
||||
|
||||
expect(screen.getByText('Trending banners: 0')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
|
||||
const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn())
|
||||
const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn())
|
||||
@ -149,3 +151,79 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const createPlugin = (pluginID: string, category: PluginCategoryEnum) =>
|
||||
({
|
||||
plugin_id: pluginID,
|
||||
type: 'plugin',
|
||||
category,
|
||||
}) as Plugin
|
||||
|
||||
const createInfiniteData = (plugin: Plugin, pageSize: number) => ({
|
||||
pages: [
|
||||
{
|
||||
plugins: [plugin],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
},
|
||||
],
|
||||
pageParams: [1],
|
||||
})
|
||||
|
||||
const createWrapperWithQueryClient = (queryClient: QueryClient) =>
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
describe('useMarketplacePlugins', () => {
|
||||
it('should reset local query params without removing marketplace plugin caches', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
},
|
||||
})
|
||||
const toolPlugin = createPlugin('tool-plugin', PluginCategoryEnum.tool)
|
||||
const modelPlugin = createPlugin('model-plugin', PluginCategoryEnum.model)
|
||||
const toolParams = {
|
||||
query: 'search',
|
||||
category: PluginCategoryEnum.tool,
|
||||
type: 'plugin' as const,
|
||||
page_size: 40,
|
||||
}
|
||||
const modelParams = {
|
||||
query: '',
|
||||
category: PluginCategoryEnum.model,
|
||||
type: 'plugin' as const,
|
||||
page_size: 1000,
|
||||
}
|
||||
const toolQueryKey = ['marketplacePlugins', toolParams]
|
||||
const modelQueryKey = ['marketplacePlugins', modelParams]
|
||||
const toolQueryData = createInfiniteData(toolPlugin, toolParams.page_size)
|
||||
const modelQueryData = createInfiniteData(modelPlugin, modelParams.page_size)
|
||||
|
||||
queryClient.setQueryData(toolQueryKey, toolQueryData)
|
||||
queryClient.setQueryData(modelQueryKey, modelQueryData)
|
||||
|
||||
const { useMarketplacePlugins } = await import('../hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins(), {
|
||||
wrapper: createWrapperWithQueryClient(queryClient),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.queryPlugins(toolParams)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plugins).toEqual([toolPlugin])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.resetQueryParams()
|
||||
})
|
||||
|
||||
expect(result.current.plugins).toBeUndefined()
|
||||
expect(queryClient.getQueryData(toolQueryKey)).toEqual(toolQueryData)
|
||||
expect(queryClient.getQueryData(modelQueryKey)).toEqual(modelQueryData)
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,16 +17,21 @@ 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),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
marketplaceQuery: {
|
||||
collections: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'collections', params],
|
||||
},
|
||||
searchAdvanced: {
|
||||
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -50,6 +55,9 @@ describe('HydrateQueryClient', () => {
|
||||
mockCollectionPlugins.mockResolvedValue({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
mockSearchAdvanced.mockResolvedValue({
|
||||
data: { plugins: [], total: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('should render children within HydrationBoundary', async () => {
|
||||
@ -104,7 +112,7 @@ describe('HydrateQueryClient', () => {
|
||||
expect(state.queries[0]?.queryKey).toEqual([
|
||||
'marketplace',
|
||||
'collections',
|
||||
{ input: { query: {} } },
|
||||
{ input: { query: { limit: 20 } } },
|
||||
])
|
||||
})
|
||||
|
||||
@ -119,7 +127,28 @@ describe('HydrateQueryClient', () => {
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prefetch when category does not have collections (model)', async () => {
|
||||
it('should prefetch plugin search when q is present', async () => {
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'all', q: 'openai' }),
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
expect(mockSearchAdvanced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { kind: 'plugins' },
|
||||
body: expect.objectContaining({
|
||||
page: 1,
|
||||
query: 'openai',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefetch when category does not have collections (model)', async () => {
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
await HydrateQueryClient({
|
||||
@ -128,9 +157,10 @@ describe('HydrateQueryClient', () => {
|
||||
})
|
||||
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
expect(mockSearchAdvanced).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not prefetch when category does not have collections (bundle)', async () => {
|
||||
it('should prefetch when category does not have collections (bundle)', async () => {
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
await HydrateQueryClient({
|
||||
@ -139,5 +169,42 @@ describe('HydrateQueryClient', () => {
|
||||
})
|
||||
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
expect(mockSearchAdvanced).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the catalog shell when collections prefetch fails', async () => {
|
||||
mockCollections.mockRejectedValue(new Error('collections unavailable'))
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
const element = await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'all' }),
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
const renderClient = new QueryClient()
|
||||
const { getByText } = render(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
{element as React.ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(getByText('Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the catalog shell when plugin search prefetch fails', async () => {
|
||||
mockSearchAdvanced.mockRejectedValue(new Error('search unavailable'))
|
||||
const { HydrateQueryClient } = await import('../hydration-server')
|
||||
|
||||
const element = await HydrateQueryClient({
|
||||
searchParams: Promise.resolve({ category: 'all', q: 'openai' }),
|
||||
children: <div>Child</div>,
|
||||
})
|
||||
|
||||
const renderClient = new QueryClient()
|
||||
const { getByText } = render(
|
||||
<QueryClientProvider client={renderClient}>
|
||||
{element as React.ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(getByText('Child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
import styles from '../plugin-type-switch.module.css'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
@ -13,7 +14,7 @@ vi.mock('#i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderSwitch = (searchParams = '') => {
|
||||
const renderSwitch = (searchParams = '', props?: ComponentProps<typeof PluginTypeSwitch>) => {
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => {
|
||||
</JotaiProvider>
|
||||
)
|
||||
|
||||
return { ...render(<PluginTypeSwitch />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
return { ...render(<PluginTypeSwitch {...props} />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('PluginTypeSwitch', () => {
|
||||
@ -41,7 +42,7 @@ describe('PluginTypeSwitch', () => {
|
||||
expect(screen.getByRole('button', { name: 'category.agents' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'category.triggers' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'category.extensions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'category.bundles' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'category.bundles' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the category in the URL when selected', async () => {
|
||||
@ -56,4 +57,28 @@ describe('PluginTypeSwitch', () => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model')
|
||||
expect(modelsButton).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('exposes the selected category and updates the URL in the home variant', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSwitch('?category=all', { variant: 'home' })
|
||||
const categoryGroup = screen.getByRole('group', { name: 'allCategories' })
|
||||
|
||||
expect(categoryGroup).toHaveClass('w-full', 'justify-start', 'gap-1')
|
||||
const activeCategory = screen.getByRole('button', { name: 'category.all' })
|
||||
const inactiveCategory = screen.getByRole('button', { name: 'category.models' })
|
||||
|
||||
expect(activeCategory).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(activeCategory).toHaveClass(styles.homeItem!, styles.homeItemActive!)
|
||||
expect(inactiveCategory).toHaveClass(styles.homeItem!)
|
||||
expect(inactiveCategory).not.toHaveClass(styles.homeItemActive!)
|
||||
expect(screen.getByRole('button', { name: 'categorySingle.datasource' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'categorySingle.agent' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'category.models' }))
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(update?.searchParams.get('category')).toBe('model')
|
||||
expect(update?.options.scroll).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -163,7 +163,7 @@ describe('useMarketplacePlugins', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
it('should surface API errors instead of an empty success', async () => {
|
||||
mockSearchAdvanced.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { useMarketplacePlugins } = await import('../query')
|
||||
@ -177,11 +177,14 @@ describe('useMarketplacePlugins', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined()
|
||||
expect(result.current.isError).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.data?.pages[0]!.plugins).toEqual([])
|
||||
expect(result.current.data?.pages[0]!.total).toBe(0)
|
||||
// No synthesized page: an empty success let a backend outage render as
|
||||
// "no plugins found", suppressed retries, and permanently disabled
|
||||
// getNextPageParam for this key.
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.error).toEqual(new Error('Network error'))
|
||||
})
|
||||
|
||||
it('should determine next page correctly via getNextPageParam', async () => {
|
||||
|
||||
@ -9,6 +9,7 @@ describe('marketplace search params', () => {
|
||||
)
|
||||
expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('')
|
||||
expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([])
|
||||
expect(marketplaceSearchParamsParsers.languages.parseServerSide(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('parses supported query values with the configured parsers', () => {
|
||||
@ -23,5 +24,9 @@ describe('marketplace search params', () => {
|
||||
'rag',
|
||||
'search',
|
||||
])
|
||||
expect(marketplaceSearchParamsParsers.languages.parseServerSide('en,zh-Hans')).toEqual([
|
||||
'en',
|
||||
'zh-Hans',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockFetchPluginBanners, mockGetLocaleOnServer } = vi.hoisted(() => ({
|
||||
mockFetchPluginBanners: vi.fn(),
|
||||
mockGetLocaleOnServer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: mockGetLocaleOnServer,
|
||||
}))
|
||||
|
||||
vi.mock('../home/banners', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../home/banners')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
fetchPluginBanners: mockFetchPluginBanners,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hydration-server', () => ({
|
||||
HydrateQueryClient: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('../prefetch-marketplace-dehydrated-state', () => ({
|
||||
prefetchMarketplaceDehydratedState: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../view', () => ({
|
||||
MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => (
|
||||
<p>Server banners: {banners.length}</p>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Marketplace server entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('prefetches localized homepage banners before rendering the standalone view', async () => {
|
||||
mockGetLocaleOnServer.mockResolvedValue('en-US')
|
||||
mockFetchPluginBanners.mockResolvedValue([
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'en-US',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[])
|
||||
|
||||
const { default: Marketplace } = await import('../index')
|
||||
const element = await Marketplace({ variant: 'home' })
|
||||
|
||||
render(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>)
|
||||
|
||||
expect(screen.getByText('Server banners: 1')).toBeInTheDocument()
|
||||
expect(mockGetLocaleOnServer).toHaveBeenCalledOnce()
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
|
||||
})
|
||||
|
||||
it('starts catalog prefetch without waiting for banners to finish', async () => {
|
||||
const { prefetchMarketplaceDehydratedState } =
|
||||
await import('../prefetch-marketplace-dehydrated-state')
|
||||
let resolveBanners: (banners: PluginBanner[]) => void = () => {}
|
||||
mockGetLocaleOnServer.mockResolvedValue('en-US')
|
||||
mockFetchPluginBanners.mockImplementation(
|
||||
() =>
|
||||
new Promise<PluginBanner[]>((resolve) => {
|
||||
resolveBanners = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(prefetchMarketplaceDehydratedState).mockResolvedValue(undefined)
|
||||
|
||||
const { default: Marketplace } = await import('../index')
|
||||
const renderPromise = Marketplace({ variant: 'home', searchParams: Promise.resolve({}) })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(prefetchMarketplaceDehydratedState).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
|
||||
|
||||
resolveBanners([])
|
||||
await renderPromise
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
@ -116,6 +117,7 @@ describe('useMarketplaceData', () => {
|
||||
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
expect(result.current.pluginsTotal).toBeDefined()
|
||||
expect(mockCollections).not.toHaveBeenCalled()
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
@ -161,6 +163,35 @@ describe('useMarketplaceData', () => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('should use the server route category for hydrated standalone search', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { Wrapper } = createWrapper('?q=openai')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(() => useMarketplaceData(PLUGIN_TYPE_SEARCH_MAP.model), {
|
||||
wrapper: Wrapper,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockSearchAdvanced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
category: 'model',
|
||||
query: 'openai',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('should trigger scroll pagination via handlePageChange callback', async () => {
|
||||
// Return enough data to indicate hasNextPage (40 of 200 total)
|
||||
mockSearchAdvanced.mockResolvedValue({
|
||||
@ -287,4 +318,53 @@ describe('useMarketplaceData', () => {
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
// Regression: `isSearchMode` was derived from the raw URL value while the
|
||||
// request body used the 500ms-debounced one. Keystroke #1 therefore flipped
|
||||
// the hook into search mode with an empty query, firing a full search for ''
|
||||
// whose generic top-plugins results rendered until the real ones replaced
|
||||
// them — the wrong-results flash at the start of every search session.
|
||||
it('should never issue an empty-query search when typing starts', async () => {
|
||||
const { useMarketplaceData } = await import('../state')
|
||||
const { useSearchPluginText } = await import('../atoms')
|
||||
const { Wrapper } = createWrapper('?category=all')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = 'marketplace-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
data: useMarketplaceData(),
|
||||
setSearch: useSearchPluginText()[1],
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setSearch('openai')
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockSearchAdvanced).toHaveBeenCalled()
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
|
||||
expect(mockSearchAdvanced).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: expect.objectContaining({ query: '' }) }),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mockSearchAdvanced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: expect.objectContaining({ query: 'openai' }) }),
|
||||
expect.anything(),
|
||||
)
|
||||
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
})
|
||||
|
||||
@ -138,6 +138,34 @@ describe('getPluginDetailLinkInMarketplace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateDetailLinkInMarketplace', () => {
|
||||
it('should return the local template detail link', async () => {
|
||||
const { getTemplateDetailLinkInMarketplace } = await import('../utils')
|
||||
|
||||
expect(
|
||||
getTemplateDetailLinkInMarketplace({
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
publisher_handle: 'dify',
|
||||
publisher_unique_handle: 'dify-unique',
|
||||
}),
|
||||
).toBe('/template/dify/Legal%20Research%20Agent?templateId=template-1')
|
||||
})
|
||||
|
||||
it('should fall back to the unique publisher handle', async () => {
|
||||
const { getTemplateDetailLinkInMarketplace } = await import('../utils')
|
||||
|
||||
expect(
|
||||
getTemplateDetailLinkInMarketplace({
|
||||
id: 'template-2',
|
||||
template_name: 'Inbox',
|
||||
publisher_handle: '',
|
||||
publisher_unique_handle: 'langgenius',
|
||||
}),
|
||||
).toBe('/template/langgenius/Inbox?templateId=template-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
it('should return category condition for tool', async () => {
|
||||
const { getMarketplaceListCondition } = await import('../utils')
|
||||
@ -229,21 +257,23 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty array', async () => {
|
||||
it('should propagate fetch errors', async () => {
|
||||
mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(result).toEqual([])
|
||||
await expect(getMarketplacePluginsByCollectionId('test-collection')).rejects.toThrow(
|
||||
'Network error',
|
||||
)
|
||||
})
|
||||
|
||||
it('should send an empty body when query is omitted', async () => {
|
||||
it('should send the warmed preview limit when query is omitted', async () => {
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('../utils')
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplacePluginsByCollectionId } =
|
||||
await import('../utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith(
|
||||
@ -251,7 +281,7 @@ describe('getMarketplacePluginsByCollectionId', () => {
|
||||
params: {
|
||||
collectionId: 'test-collection',
|
||||
},
|
||||
body: {},
|
||||
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
|
||||
},
|
||||
expect.objectContaining({
|
||||
signal: undefined,
|
||||
@ -289,7 +319,8 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } })
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
|
||||
await import('../utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
@ -297,16 +328,104 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
|
||||
expect(result.marketplaceCollections).toBeDefined()
|
||||
expect(result.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { collectionId: 'collection1' },
|
||||
body: {
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty data', async () => {
|
||||
it('posts the warmed preview limit when the catalog has no extra filters', async () => {
|
||||
mockCollections.mockResolvedValueOnce({
|
||||
data: {
|
||||
collections: [
|
||||
{
|
||||
name: 'featured',
|
||||
label: {},
|
||||
description: {},
|
||||
rule: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] } })
|
||||
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } =
|
||||
await import('../utils')
|
||||
await getMarketplaceCollectionsAndPlugins()
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { collectionId: 'featured' },
|
||||
body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT },
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should propagate a failing collections request', async () => {
|
||||
mockCollections.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
|
||||
// Resolving an empty catalog here made a backend outage indistinguishable
|
||||
// from "no collections", cached as a success for the whole staleTime.
|
||||
await expect(getMarketplaceCollectionsAndPlugins()).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should keep the catalog when a single collection fails', async () => {
|
||||
mockCollections.mockResolvedValueOnce({
|
||||
data: {
|
||||
collections: [
|
||||
{ name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
{ name: 'broken', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
mockCollectionPlugins
|
||||
.mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } })
|
||||
.mockRejectedValueOnce(new Error('collection down'))
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins()
|
||||
|
||||
expect(result.marketplaceCollections).toEqual([])
|
||||
expect(result.marketplaceCollectionPluginsMap).toEqual({})
|
||||
expect(result.marketplaceCollections).toHaveLength(2)
|
||||
expect(result.marketplaceCollectionPluginsMap.ok).toHaveLength(1)
|
||||
expect(result.marketplaceCollectionPluginsMap.broken).toEqual([])
|
||||
})
|
||||
|
||||
it('propagates cancellation instead of resolving empty carousels', async () => {
|
||||
const controller = new AbortController()
|
||||
mockCollections.mockResolvedValueOnce({
|
||||
data: {
|
||||
collections: [
|
||||
{ name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
{ name: 'slow', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
mockCollectionPlugins
|
||||
.mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } })
|
||||
.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
const error = new Error('Aborted')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
})
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
|
||||
|
||||
await expect(
|
||||
getMarketplaceCollectionsAndPlugins({}, { signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('should append condition and type to URL when provided', async () => {
|
||||
@ -327,22 +446,74 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
})
|
||||
|
||||
describe('getCollectionsParams', () => {
|
||||
it('should return empty object for all category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({})
|
||||
it('should return the warmed preview limit for all category', async () => {
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
})
|
||||
expect(COLLECTION_PREVIEW_PLUGIN_LIMIT).toBe(20)
|
||||
})
|
||||
|
||||
it('should return category, condition, and type for tool category', async () => {
|
||||
const { getCollectionsParams } = await import('../utils')
|
||||
it('should return category, condition, type, and preview limit for tool category', async () => {
|
||||
const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils')
|
||||
const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool)
|
||||
expect(result).toEqual({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
limit: COLLECTION_PREVIEW_PLUGIN_LIMIT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toListPlugin', () => {
|
||||
it('keeps card fields and drops list-unused payload', async () => {
|
||||
const { toListPlugin } = await import('../utils')
|
||||
const plugin = {
|
||||
...createMockPlugin({
|
||||
introduction: 'A very long readme that must not enter the catalog RSC payload',
|
||||
}),
|
||||
resource: { memory: 256 },
|
||||
plugins: { tools: ['x'] },
|
||||
tool: { identity: { name: 'search' } },
|
||||
model: { provider: 'openai' },
|
||||
agent_strategy: { features: ['a'] },
|
||||
data_sources: { items: [] },
|
||||
triggers: { events: [] },
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
privacy_options: 'all',
|
||||
readme_meta: { available_languages: ['en_US'] },
|
||||
endpoint: { settings: [{ name: 'api_key' }] },
|
||||
} as unknown as Plugin
|
||||
|
||||
const listed = toListPlugin(plugin)
|
||||
|
||||
expect(listed.org).toBe('test-org')
|
||||
expect(listed.name).toBe('test-plugin')
|
||||
expect(listed.plugin_id).toBe('plugin-1')
|
||||
expect(listed.label).toEqual({ 'en-US': 'Test Plugin' })
|
||||
expect(listed.brief).toEqual({ 'en-US': 'Test plugin brief' })
|
||||
expect(listed.badges).toEqual([])
|
||||
expect(listed.verification).toEqual({ authorized_category: 'community' })
|
||||
expect(listed.install_count).toBe(1000)
|
||||
expect(listed.category).toBe(PluginCategoryEnum.tool)
|
||||
expect(listed.tags).toEqual([{ name: 'search' }])
|
||||
expect(listed.type).toBe('plugin')
|
||||
expect(listed.introduction).toBe('')
|
||||
expect(listed.endpoint).toEqual({ settings: [] })
|
||||
expect(listed).not.toHaveProperty('resource')
|
||||
expect(listed).not.toHaveProperty('plugins')
|
||||
expect(listed).not.toHaveProperty('tool')
|
||||
expect(listed).not.toHaveProperty('model')
|
||||
expect(listed).not.toHaveProperty('agent_strategy')
|
||||
expect(listed).not.toHaveProperty('data_sources')
|
||||
expect(listed).not.toHaveProperty('triggers')
|
||||
expect(listed).not.toHaveProperty('privacy_policy')
|
||||
expect(listed).not.toHaveProperty('privacy_options')
|
||||
expect(listed).not.toHaveProperty('readme_meta')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplacePlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -431,23 +602,15 @@ describe('getMarketplacePlugins', () => {
|
||||
expect(call![0].body.category).toBe('')
|
||||
})
|
||||
|
||||
it('should handle API error and return empty result', async () => {
|
||||
it('should propagate API errors instead of synthesizing an empty page', async () => {
|
||||
mockSearchAdvanced.mockRejectedValueOnce(new Error('API error'))
|
||||
|
||||
const { getMarketplacePlugins } = await import('../utils')
|
||||
const result = await getMarketplacePlugins(
|
||||
{
|
||||
query: 'fail',
|
||||
},
|
||||
2,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
page: 2,
|
||||
page_size: 40,
|
||||
})
|
||||
// A synthesized `{ plugins: [], total: 0 }` resolved as a *success*: no
|
||||
// isError, no retry, a cached empty result, and getNextPageParam saw
|
||||
// total 0 and killed pagination for that key permanently.
|
||||
await expect(getMarketplacePlugins({ query: 'fail' }, 2)).rejects.toThrow('API error')
|
||||
})
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/marketplace'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { DEFAULT_SORT } from './constants'
|
||||
import { marketplaceSearchParamsParsers, shouldSearchMarketplacePlugins } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
@ -21,6 +22,9 @@ export function useActivePluginType() {
|
||||
export function useFilterPluginTags() {
|
||||
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
}
|
||||
export function useFilterTemplateLanguages() {
|
||||
return useQueryState('languages', marketplaceSearchParamsParsers.languages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
@ -28,19 +32,48 @@ export function useFilterPluginTags() {
|
||||
*/
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
export function useMarketplaceSearchMode(
|
||||
activePluginTypeOverride?: ActivePluginType,
|
||||
// Callers that debounce the query text MUST pass the debounced value here.
|
||||
// Deciding "are we searching?" from the raw URL value while the request body
|
||||
// carries the debounced one flips this hook true on keystroke #1, firing a
|
||||
// wasted empty-query search whose generic top-plugins list renders for the
|
||||
// debounce window before the real results replace it. '' is a meaningful
|
||||
// override, so this is `??`, not `||`.
|
||||
searchPluginTextOverride?: string,
|
||||
) {
|
||||
const [searchPluginTextFromUrl] = useSearchPluginText()
|
||||
const searchPluginText = searchPluginTextOverride ?? searchPluginTextFromUrl
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [activePluginTypeFromUrl] = useActivePluginType()
|
||||
const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode =
|
||||
!!searchPluginText ||
|
||||
filterPluginTags.length > 0 ||
|
||||
(searchMode ?? !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))
|
||||
searchMode === true ||
|
||||
shouldSearchMarketplacePlugins({
|
||||
category: activePluginType,
|
||||
q: searchPluginText,
|
||||
tags: filterPluginTags,
|
||||
})
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
/**
|
||||
* The forced search mode lives in the app-wide Jotai store, so a "View More"
|
||||
* click would otherwise leak into the next visit of the plugin catalog after
|
||||
* navigating away (e.g. to /templates) and back, rendering empty-query search
|
||||
* results instead of the prefetched collections. Reset it when the catalog
|
||||
* route mounts; URL-owned state (q, tags, category) is not affected.
|
||||
*/
|
||||
export function useResetMarketplaceSearchModeOnMount() {
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
useEffect(() => {
|
||||
setSearchMode(null)
|
||||
}, [setSearchMode])
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [, setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
|
||||
@ -5,6 +5,12 @@ export const DEFAULT_SORT = {
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM id of the marketplace scroll container. The route components render it
|
||||
* and the scroll/viewport observers below the marketplace tree look it up.
|
||||
*/
|
||||
export const MARKETPLACE_CONTAINER_ID = 'marketplace-container'
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
|
||||
48
web/app/components/plugins/marketplace/creator-center-url.ts
Normal file
48
web/app/components/plugins/marketplace/creator-center-url.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export const PUBLIC_CREATOR_CENTER_URL = 'https://creators.dify.ai/'
|
||||
|
||||
const subscribe = () => () => {}
|
||||
|
||||
/**
|
||||
* marketplace.dify.ai → creators.dify.ai
|
||||
* marketplace.dify.dev → creators.dify.dev
|
||||
* marketplace-staging.dify.dev → creators-staging.dify.dev
|
||||
*/
|
||||
export const rewriteMarketplaceOriginToCreators = (origin: string): string | null => {
|
||||
if (!origin) return null
|
||||
|
||||
try {
|
||||
const marketplaceUrl = new URL(origin)
|
||||
const [service, ...domain] = marketplaceUrl.hostname.split('.')
|
||||
if (!service?.startsWith('marketplace') || domain.length === 0) return null
|
||||
|
||||
marketplaceUrl.hostname = [service.replace(/^marketplace/, 'creators'), ...domain].join('.')
|
||||
marketplaceUrl.pathname = '/'
|
||||
marketplaceUrl.search = ''
|
||||
marketplaceUrl.hash = ''
|
||||
return marketplaceUrl.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getCreatorCenterUrl = (marketplaceUrlPrefix: string, pageOrigin?: string): string => {
|
||||
return (
|
||||
rewriteMarketplaceOriginToCreators(pageOrigin ?? '') ||
|
||||
rewriteMarketplaceOriginToCreators(marketplaceUrlPrefix) ||
|
||||
PUBLIC_CREATOR_CENTER_URL
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the current page origin when this is the standalone Marketplace, so a
|
||||
* .dev deployment cannot inherit a baked-in .ai Creator Center URL.
|
||||
*/
|
||||
export const useCreatorCenterUrl = (marketplaceUrlPrefix: string) => {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => getCreatorCenterUrl(marketplaceUrlPrefix, window.location.origin),
|
||||
() => getCreatorCenterUrl(marketplaceUrlPrefix),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import type { CreatorCreation } from '../model'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CreationCard from '../creation-card'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span data-testid="creation-icon" />,
|
||||
}))
|
||||
|
||||
const creation: CreatorCreation = {
|
||||
id: 'plugin:dify/search',
|
||||
kind: 'plugin',
|
||||
title: 'Search',
|
||||
description: 'Search the web.',
|
||||
target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: 'search' },
|
||||
icon: { type: 'emoji', value: '🔎' },
|
||||
dependencyIcons: ['/one.png', '/two.png'],
|
||||
dependencyCount: 4,
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
popularity: 1,
|
||||
}
|
||||
|
||||
describe('CreationCard', () => {
|
||||
it('renders a host link without selecting', () => {
|
||||
render(
|
||||
<CreationCard
|
||||
creation={creation}
|
||||
action={{ type: 'link', href: '/plugin/dify/search?language=en-US' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plugin/dify/search?language=en-US',
|
||||
)
|
||||
expect(screen.getByText('+2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects in Dify without rendering a navigation target', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(<CreationCard creation={creation} action={{ type: 'select', onSelect }} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,163 @@
|
||||
import type { CreatorCreation } from '../model'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import CreatorContent from '../creator-content'
|
||||
|
||||
const publisherMocks = vi.hoisted(() => ({
|
||||
fetchPublisherPluginPage: vi.fn(),
|
||||
fetchPublisherTemplatePage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../publisher', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../publisher')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage,
|
||||
fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
const translations: Record<string, string> = {
|
||||
'marketplace.creatorProfile.creations': 'Creations',
|
||||
'marketplace.creatorProfile.sortBy': 'Sort by',
|
||||
'marketplace.creatorProfile.sort.updatedAt': 'Recently updated',
|
||||
'marketplace.creatorProfile.sort.createdAt': 'Recently created',
|
||||
'marketplace.creatorProfile.sort.popularity': 'Most popular',
|
||||
'marketplace.creatorProfile.sort.asc': 'Sort ascending',
|
||||
'marketplace.creatorProfile.sort.desc': 'Sort descending',
|
||||
'marketplace.creatorProfile.type.plugin': 'Plugin',
|
||||
'marketplace.creatorProfile.type.template': 'Template',
|
||||
'marketplace.creatorProfile.loadMore': 'Load more',
|
||||
'marketplace.creatorProfile.loadMoreFailed': "Couldn't load more creations.",
|
||||
}
|
||||
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => translations[key] ?? key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span aria-hidden />,
|
||||
}))
|
||||
|
||||
const createCreation = (
|
||||
id: string,
|
||||
title: string,
|
||||
updatedAt: number,
|
||||
createdAt: number,
|
||||
popularity: number,
|
||||
): CreatorCreation => ({
|
||||
id,
|
||||
kind: 'plugin',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: id },
|
||||
icon: { type: 'emoji', value: 'P' },
|
||||
dependencyIcons: [],
|
||||
dependencyCount: 0,
|
||||
updatedAt,
|
||||
createdAt,
|
||||
popularity,
|
||||
})
|
||||
|
||||
const creations = [
|
||||
createCreation('alpha', 'Alpha', 2, 3, 1),
|
||||
createCreation('bravo', 'Bravo', 3, 1, 2),
|
||||
createCreation('charlie', 'Charlie', 1, 2, 3),
|
||||
]
|
||||
|
||||
const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label'))
|
||||
|
||||
describe('CreatorContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes sort into the URL and reorders the current cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(
|
||||
<CreatorContent
|
||||
creations={creations}
|
||||
getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(cardNames()).toEqual(['Bravo', 'Alpha', 'Charlie'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sort by Recently updated' }))
|
||||
const recentlyUpdatedOption = screen.getByRole('menuitemradio', {
|
||||
name: 'Recently updated',
|
||||
})
|
||||
const mostPopularOption = screen.getByRole('menuitemradio', { name: 'Most popular' })
|
||||
expect(recentlyUpdatedOption).toHaveAttribute('aria-checked', 'true')
|
||||
expect(mostPopularOption).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(mostPopularOption)
|
||||
await waitFor(() => {
|
||||
expect(cardNames()).toEqual(['Charlie', 'Bravo', 'Alpha'])
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sort ascending' }))
|
||||
await waitFor(() => {
|
||||
expect(cardNames()).toEqual(['Alpha', 'Bravo', 'Charlie'])
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_order')).toBe('asc')
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the next publisher pages when more creations exist', async () => {
|
||||
const user = userEvent.setup()
|
||||
publisherMocks.fetchPublisherPluginPage.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'delta',
|
||||
labels: { 'en-US': 'Delta' },
|
||||
brief: { 'en-US': 'Delta plugin' },
|
||||
install_count: 4,
|
||||
created_at: '2026-01-04T00:00:00Z',
|
||||
updated_at: '2026-02-04T00:00:00Z',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
})
|
||||
publisherMocks.fetchPublisherTemplatePage.mockResolvedValue({ items: [], hasMore: false })
|
||||
|
||||
renderWithNuqs(
|
||||
<CreatorContent
|
||||
creations={creations}
|
||||
locale="en-US"
|
||||
inventory={{
|
||||
uniqueHandle: 'scarlettmao',
|
||||
pluginHasMore: true,
|
||||
templateHasMore: false,
|
||||
pluginNextPage: 2,
|
||||
templateNextPage: 2,
|
||||
}}
|
||||
getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Load more' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cardNames()).toContain('Delta')
|
||||
})
|
||||
expect(publisherMocks.fetchPublisherPluginPage).toHaveBeenCalledWith({
|
||||
uniqueHandle: 'scarlettmao',
|
||||
page: 2,
|
||||
sortField: 'updatedAt',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
expect(publisherMocks.fetchPublisherTemplatePage).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,83 @@
|
||||
import type { CreatorProfileViewModel } from '../model'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CreatorSidebar from '../creator-sidebar'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../publisher-avatar', () => ({
|
||||
default: ({ className, size }: { className?: string; size?: number }) => (
|
||||
<div data-testid="publisher-avatar" data-size={size} className={className} />
|
||||
),
|
||||
}))
|
||||
|
||||
const profile: CreatorProfileViewModel['profile'] = {
|
||||
kind: 'individual',
|
||||
displayName: 'Creator',
|
||||
handle: 'creator',
|
||||
avatarUrl: '',
|
||||
backgroundUrl: '',
|
||||
badges: [],
|
||||
socialLinks: [
|
||||
{ platform: 'website', href: 'https://example.com/', label: 'example.com' },
|
||||
{ platform: 'x', href: 'https://x.com/creator', label: 'x.com/creator' },
|
||||
{
|
||||
platform: 'instagram',
|
||||
href: 'https://instagram.com/creator',
|
||||
label: 'instagram.com/creator',
|
||||
},
|
||||
{
|
||||
platform: 'youtube',
|
||||
href: 'https://youtube.com/creator',
|
||||
label: 'youtube.com/creator',
|
||||
},
|
||||
{ platform: 'figma', href: 'https://figma.com/@creator', label: 'figma.com/@creator' },
|
||||
{ platform: 'github', href: 'https://github.com/creator', label: 'github.com/creator' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('CreatorSidebar social links', () => {
|
||||
it('adds a light shadow without changing the avatar geometry', () => {
|
||||
render(<CreatorSidebar profile={profile} />)
|
||||
|
||||
const avatar = screen.getByTestId('publisher-avatar')
|
||||
|
||||
expect(avatar).toHaveClass('shadow-xs')
|
||||
expect(avatar).toHaveClass(
|
||||
'absolute',
|
||||
'-top-12',
|
||||
'-left-2',
|
||||
'!size-20',
|
||||
'border-[1.5px]',
|
||||
'md:-top-[68px]',
|
||||
'md:!size-[100px]',
|
||||
)
|
||||
expect(avatar).toHaveAttribute('data-size', '100')
|
||||
})
|
||||
|
||||
it('renders a static platform icon at the start of every social row', () => {
|
||||
render(<CreatorSidebar profile={profile} />)
|
||||
|
||||
const expectedClasses = [
|
||||
['example.com', 'i-ri-global-line'],
|
||||
['x.com/creator', 'i-ri-twitter-x-fill'],
|
||||
['instagram.com/creator', 'i-ri-instagram-line'],
|
||||
['youtube.com/creator', 'i-ri-youtube-fill'],
|
||||
['figma.com/@creator', 'i-ri-figma-line'],
|
||||
['github.com/creator', 'i-ri-github-fill'],
|
||||
]
|
||||
|
||||
for (const [name, iconClass] of expectedClasses) {
|
||||
const link = screen.getByRole('link', { name })
|
||||
expect(link.firstElementChild).toHaveClass(iconClass!)
|
||||
expect(link.firstElementChild).toHaveClass('size-4')
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,276 @@
|
||||
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadCreatorProfile } from '../data.server'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
creatorDetail: vi.fn(),
|
||||
organizationDetail: vi.fn(),
|
||||
publisherPlugins: vi.fn(),
|
||||
publisherTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1' }))
|
||||
vi.mock('@/service/client', () => ({ marketplaceClient: mocks }))
|
||||
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'search',
|
||||
plugin_id: 'dify/search',
|
||||
label: { en_US: 'Search' },
|
||||
brief: { en_US: 'Search the web.' },
|
||||
tags: [],
|
||||
} as unknown as MarketplacePlugin
|
||||
|
||||
const template = {
|
||||
id: 'template-one',
|
||||
template_name: 'Template one',
|
||||
overview: 'Build an app.',
|
||||
icon: '📄',
|
||||
icon_background: '#fff',
|
||||
icon_file_key: '',
|
||||
usage_count: 1,
|
||||
categories: [],
|
||||
} as MarketplaceTemplate
|
||||
|
||||
describe('loadCreatorProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.creatorDetail.mockResolvedValue({
|
||||
data: {
|
||||
creator: {
|
||||
unique_handle: 'creator',
|
||||
display_name: 'Creator',
|
||||
social_links: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
mocks.organizationDetail.mockResolvedValue({ data: {} })
|
||||
mocks.publisherPlugins.mockResolvedValue({ data: { plugins: [plugin] } })
|
||||
mocks.publisherTemplates.mockResolvedValue({ data: { templates: [template] } })
|
||||
})
|
||||
|
||||
it('loads individual data through all publisher contracts', async () => {
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-one',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(mocks.creatorDetail).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'creator-one' },
|
||||
})
|
||||
expect(mocks.publisherPlugins).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'creator-one' },
|
||||
query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
|
||||
})
|
||||
expect(loaded?.viewModel.creations).toHaveLength(2)
|
||||
expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined()
|
||||
expect(loaded?.viewModel.profile.backgroundUrl).toBe('')
|
||||
expect(loaded?.viewModel.profile.avatarUrl).toBe('')
|
||||
})
|
||||
|
||||
it('only emits the remote background URL when the API reports an uploaded background', async () => {
|
||||
mocks.creatorDetail.mockResolvedValue({
|
||||
data: {
|
||||
creator: {
|
||||
unique_handle: 'creator-with-background',
|
||||
display_name: 'Creator with background',
|
||||
background_image: 'creator/background.png',
|
||||
social_links: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-with-background',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(loaded?.viewModel.profile.backgroundUrl).toBe(
|
||||
'https://marketplace.example/api/v1/creators/creator-with-background/background-image',
|
||||
)
|
||||
})
|
||||
|
||||
it('only emits the remote avatar URL when the API reports an uploaded avatar', async () => {
|
||||
mocks.creatorDetail.mockResolvedValue({
|
||||
data: {
|
||||
creator: {
|
||||
unique_handle: 'creator-with-avatar',
|
||||
display_name: 'Creator with avatar',
|
||||
avatar: 'creator/avatar.png',
|
||||
social_links: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-with-avatar',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(loaded?.viewModel.profile.avatarUrl).toBe(
|
||||
'https://marketplace.example/api/v1/creators/creator-with-avatar/avatar',
|
||||
)
|
||||
})
|
||||
|
||||
it('loads evanz from the Marketplace API without a development fixture branch', async () => {
|
||||
await loadCreatorProfile({ uniqueHandle: 'evanz', locale: 'en-US' })
|
||||
|
||||
expect(mocks.creatorDetail).toHaveBeenCalledWith({ params: { uniqueHandle: 'evanz' } })
|
||||
expect(mocks.publisherTemplates).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'evanz' },
|
||||
query: { page: 1, page_size: 40, sort_by: 'updated_at', sort_order: 'DESC' },
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards popularity sort to each publisher API column', async () => {
|
||||
await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-one',
|
||||
locale: 'en-US',
|
||||
sortBy: 'popularity',
|
||||
sortOrder: 'asc',
|
||||
})
|
||||
|
||||
expect(mocks.publisherPlugins).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'creator-one' },
|
||||
query: { page: 1, page_size: 40, sort_by: 'install_count', sort_order: 'ASC' },
|
||||
})
|
||||
expect(mocks.publisherTemplates).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'creator-one' },
|
||||
query: { page: 1, page_size: 40, sort_by: 'usage_count', sort_order: 'ASC' },
|
||||
})
|
||||
})
|
||||
|
||||
it('merge-sorts mixed creations after the publisher responses return', async () => {
|
||||
mocks.publisherPlugins.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [{ ...plugin, install_count: 2, created_at: '2026-01-01T00:00:00Z' }],
|
||||
},
|
||||
})
|
||||
mocks.publisherTemplates.mockResolvedValue({
|
||||
data: {
|
||||
templates: [{ ...template, usage_count: 5, created_at: '2026-01-02T00:00:00Z' }],
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-one',
|
||||
locale: 'en-US',
|
||||
sortBy: 'popularity',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
|
||||
expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin'])
|
||||
})
|
||||
|
||||
it('loads only the first publisher page and reports remaining inventory', async () => {
|
||||
mocks.publisherPlugins.mockResolvedValue({
|
||||
data: {
|
||||
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
|
||||
total: 90,
|
||||
},
|
||||
})
|
||||
mocks.publisherTemplates.mockResolvedValue({
|
||||
data: { templates: [template], total: 1 },
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'paged-creator',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
|
||||
expect(mocks.publisherPlugins).toHaveBeenCalledWith({
|
||||
params: { uniqueHandle: 'paged-creator' },
|
||||
query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
|
||||
})
|
||||
expect(loaded?.inventory).toMatchObject({
|
||||
uniqueHandle: 'paged-creator',
|
||||
pluginHasMore: true,
|
||||
templateHasMore: false,
|
||||
pluginNextPage: 2,
|
||||
})
|
||||
expect(loaded?.viewModel.creations).toHaveLength(41)
|
||||
})
|
||||
|
||||
it('does not treat a full first page as the complete inventory when total is missing', async () => {
|
||||
mocks.publisherPlugins.mockResolvedValue({
|
||||
data: {
|
||||
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'uncounted-creator',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
|
||||
expect(loaded?.inventory.pluginHasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps successful creations when one publisher request fails', async () => {
|
||||
mocks.publisherPlugins.mockRejectedValue(new Error('plugin request failed'))
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'creator-partial',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template'])
|
||||
})
|
||||
|
||||
it('returns null when the primary creator does not exist', async () => {
|
||||
mocks.creatorDetail.mockResolvedValue({ data: {} })
|
||||
|
||||
await expect(
|
||||
loadCreatorProfile({ uniqueHandle: 'missing-creator', locale: 'en-US' }),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rethrows when the primary creator request fails', async () => {
|
||||
mocks.creatorDetail.mockRejectedValue(new Error('creator request timed out'))
|
||||
|
||||
await expect(
|
||||
loadCreatorProfile({ uniqueHandle: 'slow-creator', locale: 'en-US' }),
|
||||
).rejects.toThrow('creator request timed out')
|
||||
})
|
||||
|
||||
it('rethrows when the organization request fails', async () => {
|
||||
mocks.organizationDetail.mockRejectedValue(new Error('organization request timed out'))
|
||||
|
||||
await expect(
|
||||
loadCreatorProfile({
|
||||
uniqueHandle: 'slow-org',
|
||||
publisherType: 'organization',
|
||||
locale: 'en-US',
|
||||
}),
|
||||
).rejects.toThrow('organization request timed out')
|
||||
})
|
||||
|
||||
it('maps organizations to the shared creator profile shape', async () => {
|
||||
mocks.organizationDetail.mockResolvedValue({
|
||||
data: {
|
||||
organization: {
|
||||
id: 'org-id',
|
||||
unique_handle: 'dify-org',
|
||||
display_name: 'Dify Org',
|
||||
social_links: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const loaded = await loadCreatorProfile({
|
||||
uniqueHandle: 'dify-org',
|
||||
publisherType: 'organization',
|
||||
locale: 'en-US',
|
||||
})
|
||||
|
||||
expect(mocks.organizationDetail).toHaveBeenCalledWith({ params: { id: 'dify-org' } })
|
||||
expect(loaded?.viewModel.profile).toMatchObject({
|
||||
kind: 'organization',
|
||||
displayName: 'Dify Org',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,213 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { MarketplaceSearchSelection } from '../../home/marketplace-search-autocomplete'
|
||||
import type { LoadedCreatorProfile } from '../model'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import DifyCreatorProfile from '../dify-profile'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
installedInfo: { 'dify/deep_research': { version: '0.0.1' } },
|
||||
}))
|
||||
|
||||
const deepResearchPlugin = {
|
||||
type: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'deep_research',
|
||||
plugin_id: 'dify/deep_research',
|
||||
latest_package_identifier: 'dify/deep_research:0.0.1@test',
|
||||
label: { 'en-US': 'Deep Research' },
|
||||
brief: { 'en-US': 'Research the web.' },
|
||||
} as unknown as Plugin
|
||||
|
||||
const searchPlugin = {
|
||||
...deepResearchPlugin,
|
||||
name: 'search_result',
|
||||
plugin_id: 'dify/search_result',
|
||||
latest_package_identifier: 'dify/search_result:0.0.1@test',
|
||||
label: { 'en-US': 'Search result' },
|
||||
} as Plugin
|
||||
|
||||
const template: MarketplaceTemplate = {
|
||||
id: 'template-one',
|
||||
template_name: 'Research Template',
|
||||
overview: 'Build a research app.',
|
||||
icon: 'R',
|
||||
icon_background: '#fff',
|
||||
icon_file_key: '',
|
||||
publisher_unique_handle: 'dify',
|
||||
usage_count: 1,
|
||||
categories: [],
|
||||
}
|
||||
|
||||
const loadedProfile: LoadedCreatorProfile = {
|
||||
viewModel: {
|
||||
profile: {
|
||||
kind: 'individual',
|
||||
displayName: 'Creator',
|
||||
handle: 'creator',
|
||||
avatarUrl: '',
|
||||
backgroundUrl: '',
|
||||
badges: [],
|
||||
socialLinks: [],
|
||||
},
|
||||
creations: [
|
||||
{
|
||||
id: 'plugin:dify/deep_research',
|
||||
kind: 'plugin',
|
||||
title: 'Deep Research',
|
||||
description: 'Research the web.',
|
||||
target: {
|
||||
type: 'plugin',
|
||||
pluginType: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'deep_research',
|
||||
},
|
||||
icon: { type: 'emoji', value: 'R' },
|
||||
dependencyIcons: [],
|
||||
dependencyCount: 0,
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
popularity: 1,
|
||||
},
|
||||
{
|
||||
id: 'template:template-one',
|
||||
kind: 'template',
|
||||
title: 'Research Template',
|
||||
description: 'Build a research app.',
|
||||
target: {
|
||||
type: 'template',
|
||||
id: 'template-one',
|
||||
publisher: 'dify',
|
||||
templateName: 'Research Template',
|
||||
},
|
||||
icon: { type: 'emoji', value: 'R' },
|
||||
dependencyIcons: [],
|
||||
dependencyCount: 0,
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
popularity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
pluginsByCreationId: {
|
||||
'plugin:dify/deep_research': deepResearchPlugin,
|
||||
},
|
||||
templatesByCreationId: {
|
||||
'template:template-one': template,
|
||||
},
|
||||
inventory: {
|
||||
uniqueHandle: 'creator',
|
||||
pluginHasMore: false,
|
||||
templateHasMore: false,
|
||||
pluginNextPage: 2,
|
||||
templateNextPage: 2,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span aria-hidden />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: mocks.installedInfo }),
|
||||
}))
|
||||
|
||||
vi.mock('../../detail-dialog', () => ({
|
||||
default: ({ isInstalled, plugin }: { isInstalled: boolean; plugin: { name: string } }) => (
|
||||
<div role="dialog" aria-label="plugin-detail">
|
||||
<span>{plugin.name}</span>
|
||||
<span>{isInstalled ? 'installed' : 'not installed'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../templates/template-detail-dialog', () => ({
|
||||
default: ({
|
||||
onInstall,
|
||||
template,
|
||||
}: {
|
||||
onInstall: () => void
|
||||
template: { template_name: string }
|
||||
}) => (
|
||||
<div role="dialog" aria-label="template-detail">
|
||||
<span>{template.template_name}</span>
|
||||
<button type="button" onClick={onInstall}>
|
||||
Install template
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
default: ({
|
||||
onSuggestionSelect,
|
||||
}: {
|
||||
onSuggestionSelect: (selection: MarketplaceSearchSelection) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSuggestionSelect({ kind: 'plugin', plugin: searchPlugin })
|
||||
}}
|
||||
>
|
||||
Select search plugin
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DifyCreatorProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the existing plugin detail flow with installed state', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Deep Research' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'plugin-detail' })
|
||||
expect(dialog).toHaveTextContent('deep_research')
|
||||
expect(dialog).toHaveTextContent('installed')
|
||||
expect(screen.queryByTestId('install-plugin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a template detail and imports it inside Dify', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Research Template' }))
|
||||
expect(screen.getByRole('dialog', { name: 'template-detail' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Install template' }))
|
||||
expect(mocks.push).toHaveBeenCalledWith('/apps?template-id=template-one')
|
||||
})
|
||||
|
||||
it('opens search results in the same plugin dialog controller', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select search plugin' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'plugin-detail' })
|
||||
expect(dialog).toHaveTextContent('search_result')
|
||||
expect(dialog).toHaveTextContent('not installed')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CreatorProfileHeader from '../header'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
const translations: Record<string, string> = {
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.creatorProfile.searchPlaceholder': 'Search plugins or templates',
|
||||
'mainNav.marketplace': 'Marketplace',
|
||||
}
|
||||
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => translations[key] ?? key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../home/home-guide', () => ({
|
||||
default: () => <div data-testid="marketplace-guide" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../home/marketplace-search-autocomplete', () => ({
|
||||
MarketplaceSearchAutocomplete: () => <div data-testid="marketplace-search" />,
|
||||
}))
|
||||
|
||||
describe('CreatorProfileHeader', () => {
|
||||
it('returns to the native Marketplace without marking a catalog tab active', () => {
|
||||
render(<CreatorProfileHeader locale="en-US" onSuggestionSelect={vi.fn()} />)
|
||||
|
||||
const pluginsLink = screen.getByRole('link', { name: 'Plugins' })
|
||||
const templatesLink = screen.getByRole('link', { name: 'Templates' })
|
||||
|
||||
expect(pluginsLink).toHaveAttribute('href', '/marketplace')
|
||||
expect(pluginsLink).not.toHaveAttribute('aria-current')
|
||||
expect(pluginsLink).not.toHaveClass('bg-state-base-active')
|
||||
expect(templatesLink).not.toHaveAttribute('aria-current')
|
||||
expect(templatesLink).not.toHaveClass('bg-state-base-active')
|
||||
expect(screen.getByTestId('marketplace-guide')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('account-section')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,224 @@
|
||||
import type {
|
||||
MarketplaceCreator,
|
||||
MarketplacePlugin,
|
||||
MarketplaceTemplate,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
adaptCreatorProfile,
|
||||
getStandaloneCreationHref,
|
||||
normalizeCreatorSocialLink,
|
||||
parseCreatorSortField,
|
||||
parseCreatorSortOrder,
|
||||
sortCreatorCreations,
|
||||
toPublisherSortQuery,
|
||||
} from '../model'
|
||||
|
||||
const creator: MarketplaceCreator = {
|
||||
unique_handle: 'evanz',
|
||||
display_name: 'Evan.Z',
|
||||
social_links: ['github.com/evanz', 'javascript:alert(1)'],
|
||||
badges: ['partner'],
|
||||
verified: true,
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
type: 'bundle',
|
||||
org: 'dify',
|
||||
name: 'research',
|
||||
labels: { en_US: 'Research bundle' },
|
||||
description: { en_US: 'Research reliably.' },
|
||||
install_count: 20,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-02-01T00:00:00Z',
|
||||
} as unknown as MarketplacePlugin
|
||||
|
||||
const template = {
|
||||
id: 'template/one',
|
||||
template_name: 'Research template',
|
||||
overview: 'Start a research app.',
|
||||
icon: '📄',
|
||||
icon_background: '#fff',
|
||||
icon_file_key: '',
|
||||
publisher_unique_handle: 'dify',
|
||||
usage_count: 10,
|
||||
categories: [],
|
||||
deps_plugins: ['dify/search'],
|
||||
created_at: '2026-01-02T00:00:00Z',
|
||||
updated_at: '2026-02-02T00:00:00Z',
|
||||
} as MarketplaceTemplate
|
||||
|
||||
describe('creator profile model', () => {
|
||||
it('normalizes DTOs into host-neutral creation targets and safe social links', () => {
|
||||
const viewModel = adaptCreatorProfile({
|
||||
creator,
|
||||
kind: 'organization',
|
||||
locale: 'en-US',
|
||||
avatarUrl: '/avatar',
|
||||
backgroundUrl: '/background',
|
||||
plugins: [plugin],
|
||||
templates: [template],
|
||||
resolvePluginIcon: () => '/plugin-icon',
|
||||
resolveTemplateIcon: () => '',
|
||||
resolveDependencyIcon: (id) => `/dependency/${id}`,
|
||||
})
|
||||
|
||||
expect(viewModel.profile.badges).toEqual(['partner', 'verified'])
|
||||
expect(viewModel.profile.socialLinks).toEqual([
|
||||
expect.objectContaining({ platform: 'github', href: 'https://github.com/evanz' }),
|
||||
])
|
||||
expect(viewModel.creations[0]).toMatchObject({
|
||||
title: 'Research bundle',
|
||||
target: { type: 'plugin', pluginType: 'bundle', org: 'dify', name: 'research' },
|
||||
})
|
||||
expect(viewModel.creations[1]).toMatchObject({
|
||||
target: {
|
||||
type: 'template',
|
||||
id: 'template/one',
|
||||
publisher: 'dify',
|
||||
templateName: 'Research template',
|
||||
},
|
||||
dependencyCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds standalone plugin, bundle, and template URLs outside the shared model', () => {
|
||||
const viewModel = adaptCreatorProfile({
|
||||
creator,
|
||||
kind: 'individual',
|
||||
locale: 'en-US',
|
||||
avatarUrl: '',
|
||||
backgroundUrl: '',
|
||||
plugins: [plugin],
|
||||
templates: [template],
|
||||
resolvePluginIcon: () => '',
|
||||
resolveTemplateIcon: () => '',
|
||||
resolveDependencyIcon: () => '',
|
||||
})
|
||||
|
||||
expect(getStandaloneCreationHref(viewModel.creations[0]!, 'zh-Hans')).toBe(
|
||||
'/bundles/dify/research?language=zh-Hans',
|
||||
)
|
||||
expect(getStandaloneCreationHref(viewModel.creations[1]!, 'zh-Hans')).toBe(
|
||||
'/template/dify/Research%20template?templateId=template%2Fone&creationType=templates&language=zh-Hans',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes Unix-second, Unix-millisecond, and ISO timestamps', () => {
|
||||
const unixSeconds = 1_767_225_600
|
||||
const unixMilliseconds = 1_767_225_700_000
|
||||
const viewModel = adaptCreatorProfile({
|
||||
creator,
|
||||
kind: 'individual',
|
||||
locale: 'en-US',
|
||||
avatarUrl: '',
|
||||
backgroundUrl: '',
|
||||
plugins: [
|
||||
{
|
||||
...plugin,
|
||||
created_at: unixSeconds,
|
||||
version_updated_at: unixSeconds + 100,
|
||||
},
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
...template,
|
||||
created_at: '2026-01-02T00:00:00Z',
|
||||
updated_at: unixMilliseconds,
|
||||
},
|
||||
],
|
||||
resolvePluginIcon: () => '',
|
||||
resolveTemplateIcon: () => '',
|
||||
resolveDependencyIcon: () => '',
|
||||
})
|
||||
|
||||
expect(viewModel.creations[0]).toMatchObject({
|
||||
createdAt: unixSeconds * 1000,
|
||||
updatedAt: (unixSeconds + 100) * 1000,
|
||||
})
|
||||
expect(viewModel.creations[1]).toMatchObject({
|
||||
createdAt: Date.parse('2026-01-02T00:00:00Z'),
|
||||
updatedAt: unixMilliseconds,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps each UI sort onto the matching plugin and template API columns', () => {
|
||||
expect(toPublisherSortQuery('updatedAt', 'desc')).toEqual({
|
||||
plugins: { sort_by: 'version_updated_at', sort_order: 'DESC' },
|
||||
templates: { sort_by: 'updated_at', sort_order: 'DESC' },
|
||||
})
|
||||
expect(toPublisherSortQuery('createdAt', 'asc')).toEqual({
|
||||
plugins: { sort_by: 'created_at', sort_order: 'ASC' },
|
||||
templates: { sort_by: 'created_at', sort_order: 'ASC' },
|
||||
})
|
||||
expect(toPublisherSortQuery('popularity', 'desc')).toEqual({
|
||||
plugins: { sort_by: 'install_count', sort_order: 'DESC' },
|
||||
templates: { sort_by: 'usage_count', sort_order: 'DESC' },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to recently updated descending for unknown URL sort values', () => {
|
||||
expect(parseCreatorSortField('garbage')).toBe('updatedAt')
|
||||
expect(parseCreatorSortField(undefined)).toBe('updatedAt')
|
||||
expect(parseCreatorSortOrder('sideways')).toBe('desc')
|
||||
expect(parseCreatorSortOrder('ASC')).toBe('asc')
|
||||
})
|
||||
|
||||
it('sorts all fields in both directions and preserves equal-value order', () => {
|
||||
const creations = [
|
||||
{ id: 'first', updatedAt: 1, createdAt: 3, popularity: 2 },
|
||||
{ id: 'second', updatedAt: 1, createdAt: 2, popularity: 3 },
|
||||
{ id: 'third', updatedAt: 2, createdAt: 1, popularity: 1 },
|
||||
] as ReturnType<typeof adaptCreatorProfile>['creations']
|
||||
|
||||
expect(sortCreatorCreations(creations, 'updatedAt', 'asc').map(({ id }) => id)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
])
|
||||
expect(sortCreatorCreations(creations, 'createdAt', 'desc').map(({ id }) => id)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
])
|
||||
expect(sortCreatorCreations(creations, 'popularity', 'desc').map(({ id }) => id)).toEqual([
|
||||
'second',
|
||||
'first',
|
||||
'third',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects unsafe URL schemes', () => {
|
||||
expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull()
|
||||
expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores non-string social links and template dependencies instead of throwing', () => {
|
||||
expect(normalizeCreatorSocialLink({ href: 'https://x.com/x' })).toBeNull()
|
||||
expect(normalizeCreatorSocialLink(null)).toBeNull()
|
||||
|
||||
const viewModel = adaptCreatorProfile({
|
||||
creator: {
|
||||
...creator,
|
||||
social_links: [{ href: 'https://x.com/x' }, 'github.com/evanz'] as unknown as string[],
|
||||
},
|
||||
kind: 'individual',
|
||||
locale: 'en-US',
|
||||
avatarUrl: '/avatar',
|
||||
backgroundUrl: '/background',
|
||||
plugins: [],
|
||||
templates: [
|
||||
{
|
||||
...template,
|
||||
deps_plugins: [null, 'dify/search', ''] as unknown as string[],
|
||||
},
|
||||
],
|
||||
resolvePluginIcon: () => '/plugin-icon',
|
||||
resolveTemplateIcon: () => '',
|
||||
resolveDependencyIcon: (id) => `/dependency/${id}`,
|
||||
})
|
||||
|
||||
expect(viewModel.profile.socialLinks).toEqual([expect.objectContaining({ platform: 'github' })])
|
||||
expect(viewModel.creations[0]?.dependencyIcons).toEqual(['/dependency/dify/search'])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import type { CreatorProfileViewModel } from '../model'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import CreatorProfileView from '../view'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../creator-sidebar', () => ({
|
||||
default: () => <aside>Creator sidebar</aside>,
|
||||
}))
|
||||
|
||||
vi.mock('../creator-content', () => ({
|
||||
default: () => (
|
||||
<section data-testid="creator-creations" style={{ height: 640, flexShrink: 0 }}>
|
||||
Creator content
|
||||
</section>
|
||||
),
|
||||
}))
|
||||
|
||||
const profile: CreatorProfileViewModel = {
|
||||
profile: {
|
||||
kind: 'individual',
|
||||
displayName: 'Creator',
|
||||
handle: 'creator',
|
||||
avatarUrl: '',
|
||||
backgroundUrl: '',
|
||||
badges: [],
|
||||
socialLinks: [],
|
||||
},
|
||||
creations: [],
|
||||
}
|
||||
|
||||
describe('CreatorProfileView layout', () => {
|
||||
it('keeps the profile background behind content taller than its scrollport', async () => {
|
||||
const screen = await render(
|
||||
<div
|
||||
data-testid="creator-scrollport"
|
||||
style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }}
|
||||
>
|
||||
<CreatorProfileView
|
||||
profile={profile}
|
||||
homeHref="/"
|
||||
isMarketplacePlatform={false}
|
||||
getCreationAction={() => ({ type: 'link', href: '/' })}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const scrollport = screen.getByTestId('creator-scrollport').element()
|
||||
const profileRoot = scrollport.firstElementChild as HTMLElement
|
||||
const creations = screen.getByTestId('creator-creations').element()
|
||||
|
||||
expect(profileRoot.getBoundingClientRect().bottom).toBeGreaterThanOrEqual(
|
||||
creations.getBoundingClientRect().bottom,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,88 @@
|
||||
import type { CreatorProfileViewModel } from '../model'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CreatorProfileView from '../view'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../creator-sidebar', () => ({
|
||||
default: () => <aside>Creator sidebar</aside>,
|
||||
}))
|
||||
|
||||
vi.mock('../creator-content', () => ({
|
||||
default: () => <section>Creator content</section>,
|
||||
}))
|
||||
|
||||
const profile: CreatorProfileViewModel = {
|
||||
profile: {
|
||||
kind: 'individual',
|
||||
displayName: 'Creator',
|
||||
handle: 'creator',
|
||||
avatarUrl: '/creator-avatar.png',
|
||||
backgroundUrl: '/creator-background.png',
|
||||
badges: [],
|
||||
socialLinks: [],
|
||||
},
|
||||
creations: [],
|
||||
}
|
||||
|
||||
describe('CreatorProfileView SSR background', () => {
|
||||
it('includes the default background in server markup before the remote background loads', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CreatorProfileView
|
||||
profile={profile}
|
||||
homeHref="/"
|
||||
isMarketplacePlatform={false}
|
||||
getCreationAction={() => ({ type: 'link', href: '/' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('default-background.png')
|
||||
expect(markup).toContain('src="/creator-background.png"')
|
||||
})
|
||||
|
||||
it('server-renders only the default background when the profile has no background', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CreatorProfileView
|
||||
profile={{
|
||||
...profile,
|
||||
profile: { ...profile.profile, backgroundUrl: '' },
|
||||
}}
|
||||
homeHref="/"
|
||||
isMarketplacePlatform={false}
|
||||
getCreationAction={() => ({ type: 'link', href: '/' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('default-background.png')
|
||||
expect(markup).not.toContain('<img')
|
||||
expect(markup).toContain('border-0')
|
||||
})
|
||||
|
||||
it('hides a stale remote image after a loading failure', () => {
|
||||
const { container } = render(
|
||||
<CreatorProfileView
|
||||
profile={profile}
|
||||
homeHref="/"
|
||||
isMarketplacePlatform={false}
|
||||
getCreationAction={() => ({ type: 'link', href: '/' })}
|
||||
/>,
|
||||
)
|
||||
const remoteBackground = container.querySelector<HTMLImageElement>(
|
||||
'img[src="/creator-background.png"]',
|
||||
)!
|
||||
|
||||
fireEvent.error(remoteBackground)
|
||||
|
||||
expect(remoteBackground).toHaveAttribute('hidden')
|
||||
expect(remoteBackground).toHaveClass('border-0')
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import type { CreatorCreation, CreatorCreationAction } from './model'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import CornerMark from '@/app/components/plugins/card/base/corner-mark'
|
||||
import Link from '@/next/link'
|
||||
|
||||
const MAX_VISIBLE_DEPENDENCIES = 7
|
||||
|
||||
type CreationCardProps = {
|
||||
creation: CreatorCreation
|
||||
action: CreatorCreationAction
|
||||
}
|
||||
|
||||
const cardClassName =
|
||||
'group relative flex h-[152px] min-w-0 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 text-left shadow-xs outline-hidden transition-shadow hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
function CreationCardContent({ creation }: { creation: CreatorCreation }) {
|
||||
const { t } = useTranslation()
|
||||
const visibleDependencies = creation.dependencyIcons.slice(0, MAX_VISIBLE_DEPENDENCIES)
|
||||
const remainingDependencies = Math.max(0, creation.dependencyCount - visibleDependencies.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CornerMark
|
||||
text={t(($) => $[`marketplace.creatorProfile.type.${creation.kind}`], { ns: 'plugin' })}
|
||||
className={cn(
|
||||
creation.kind === 'plugin' && '[&>div]:text-text-accent',
|
||||
creation.kind === 'template' && '[&>div]:text-text-warning',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-3 px-4 pt-4 pr-20 pb-2">
|
||||
{creation.icon.type === 'image' ? (
|
||||
<AppIcon size="large" iconType="image" imageUrl={creation.icon.src} />
|
||||
) : (
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType="emoji"
|
||||
icon={creation.icon.value}
|
||||
background={creation.icon.background}
|
||||
/>
|
||||
)}
|
||||
<h3 className="min-w-0 flex-1 truncate system-md-medium text-text-primary">
|
||||
{creation.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="mx-4 line-clamp-2 min-h-8 system-xs-regular text-text-secondary">
|
||||
{creation.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex min-h-7 items-center gap-1 overflow-hidden px-4 py-1">
|
||||
{visibleDependencies.map((icon) => (
|
||||
<img
|
||||
key={icon}
|
||||
alt=""
|
||||
aria-hidden
|
||||
src={icon}
|
||||
className="size-6 shrink-0 rounded-md border-[0.5px] border-effects-icon-border object-cover"
|
||||
/>
|
||||
))}
|
||||
{remainingDependencies > 0 && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
+{remainingDependencies}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CreationCard({ creation, action }: CreationCardProps) {
|
||||
if (action.type === 'link') {
|
||||
return (
|
||||
<Link href={action.href} aria-label={creation.title} className={cardClassName}>
|
||||
<CreationCardContent creation={creation} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={creation.title}
|
||||
className={cardClassName}
|
||||
onClick={action.onSelect}
|
||||
>
|
||||
<CreationCardContent creation={creation} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type {
|
||||
CreatorCreation,
|
||||
CreatorCreationAction,
|
||||
CreatorInventory,
|
||||
CreatorSortField,
|
||||
CreatorSortOrder,
|
||||
} from './model'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { parseAsStringEnum, useQueryStates } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import CreationCard from './creation-card'
|
||||
import {
|
||||
CREATOR_SORT_FIELDS,
|
||||
DEFAULT_CREATOR_SORT_FIELD,
|
||||
DEFAULT_CREATOR_SORT_ORDER,
|
||||
sortCreatorCreations,
|
||||
} from './model'
|
||||
import { fetchPublisherPluginPage, fetchPublisherTemplatePage, toCreatorRecords } from './publisher'
|
||||
|
||||
type CreatorContentProps = {
|
||||
creations: CreatorCreation[]
|
||||
getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
|
||||
inventory?: CreatorInventory
|
||||
locale?: string
|
||||
onRecordsLoaded?: (records: {
|
||||
pluginsByCreationId: Record<string, Plugin>
|
||||
templatesByCreationId: Record<string, MarketplaceTemplate>
|
||||
}) => void
|
||||
}
|
||||
|
||||
const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false }
|
||||
const creatorSortSearchParsers = {
|
||||
sort_by: parseAsStringEnum<CreatorSortField>([...CREATOR_SORT_FIELDS]).withDefault(
|
||||
DEFAULT_CREATOR_SORT_FIELD,
|
||||
),
|
||||
sort_order: parseAsStringEnum<CreatorSortOrder>(['asc', 'desc']).withDefault(
|
||||
DEFAULT_CREATOR_SORT_ORDER,
|
||||
),
|
||||
}
|
||||
|
||||
export default function CreatorContent({
|
||||
creations,
|
||||
getCreationAction,
|
||||
inventory,
|
||||
locale = 'en-US',
|
||||
onRecordsLoaded,
|
||||
}: CreatorContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions)
|
||||
const sortField = sort.sort_by
|
||||
const sortOrder = sort.sort_order
|
||||
const [sourceCreations, setSourceCreations] = useState(creations)
|
||||
const [loadedCreations, setLoadedCreations] = useState(creations)
|
||||
const [pluginHasMore, setPluginHasMore] = useState(inventory?.pluginHasMore ?? false)
|
||||
const [templateHasMore, setTemplateHasMore] = useState(inventory?.templateHasMore ?? false)
|
||||
const [pluginNextPage, setPluginNextPage] = useState(inventory?.pluginNextPage ?? 2)
|
||||
const [templateNextPage, setTemplateNextPage] = useState(inventory?.templateNextPage ?? 2)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [loadMoreFailed, setLoadMoreFailed] = useState(false)
|
||||
if (creations !== sourceCreations) {
|
||||
setSourceCreations(creations)
|
||||
setLoadedCreations(creations)
|
||||
setPluginHasMore(inventory?.pluginHasMore ?? false)
|
||||
setTemplateHasMore(inventory?.templateHasMore ?? false)
|
||||
setPluginNextPage(inventory?.pluginNextPage ?? 2)
|
||||
setTemplateNextPage(inventory?.templateNextPage ?? 2)
|
||||
setLoadMoreFailed(false)
|
||||
}
|
||||
const sortOptions: Array<{ value: CreatorSortField; label: string }> = [
|
||||
{
|
||||
value: 'updatedAt',
|
||||
label: t(($) => $['marketplace.creatorProfile.sort.updatedAt'], { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'createdAt',
|
||||
label: t(($) => $['marketplace.creatorProfile.sort.createdAt'], { ns: 'plugin' }),
|
||||
},
|
||||
{
|
||||
value: 'popularity',
|
||||
label: t(($) => $['marketplace.creatorProfile.sort.popularity'], { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]!
|
||||
const sortedCreations = useMemo(
|
||||
() => sortCreatorCreations(loadedCreations, sortField, sortOrder),
|
||||
[loadedCreations, sortField, sortOrder],
|
||||
)
|
||||
const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
|
||||
const hasMore = pluginHasMore || templateHasMore
|
||||
const uniqueHandle = inventory?.uniqueHandle
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!uniqueHandle || isLoadingMore || !hasMore) return
|
||||
|
||||
setIsLoadingMore(true)
|
||||
setLoadMoreFailed(false)
|
||||
try {
|
||||
const [pluginPage, templatePage] = await Promise.all([
|
||||
pluginHasMore
|
||||
? fetchPublisherPluginPage({
|
||||
uniqueHandle,
|
||||
page: pluginNextPage,
|
||||
sortField,
|
||||
sortOrder,
|
||||
})
|
||||
: Promise.resolve({ items: [] as MarketplacePlugin[], hasMore: false }),
|
||||
templateHasMore
|
||||
? fetchPublisherTemplatePage({
|
||||
uniqueHandle,
|
||||
page: templateNextPage,
|
||||
sortField,
|
||||
sortOrder,
|
||||
})
|
||||
: Promise.resolve({ items: [] as MarketplaceTemplate[], hasMore: false }),
|
||||
])
|
||||
const records = toCreatorRecords({
|
||||
locale,
|
||||
plugins: pluginPage.items,
|
||||
templates: templatePage.items,
|
||||
})
|
||||
setLoadedCreations((current) => {
|
||||
const seen = new Set(current.map((creation) => creation.id))
|
||||
return [...current, ...records.creations.filter((creation) => !seen.has(creation.id))]
|
||||
})
|
||||
if (pluginHasMore) {
|
||||
setPluginHasMore(pluginPage.hasMore)
|
||||
setPluginNextPage((page) => page + 1)
|
||||
}
|
||||
if (templateHasMore) {
|
||||
setTemplateHasMore(templatePage.hasMore)
|
||||
setTemplateNextPage((page) => page + 1)
|
||||
}
|
||||
onRecordsLoaded?.(records)
|
||||
} catch {
|
||||
setLoadMoreFailed(true)
|
||||
} finally {
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="creator-creations-title"
|
||||
className="flex min-w-0 flex-1 flex-col items-start pt-6"
|
||||
>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="creator-creations-title" className="system-xl-semibold text-text-primary">
|
||||
{t(($) => $['marketplace.creatorProfile.creations'], { ns: 'plugin' })}
|
||||
</h2>
|
||||
|
||||
<div className="flex h-8 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`${t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} ${selectedSort.label}`}
|
||||
className="flex h-8 items-center rounded-lg px-2 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span className="mr-1 system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })}
|
||||
</span>
|
||||
<span className="system-sm-medium text-text-secondary">{selectedSort.label}</span>
|
||||
<span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
className="min-w-[176px] p-1"
|
||||
>
|
||||
<DropdownMenuRadioGroup<CreatorSortField>
|
||||
value={sortField}
|
||||
onValueChange={(nextField) => {
|
||||
void setSort({ sort_by: nextField, sort_order: sortOrder })
|
||||
}}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuRadioItem<CreatorSortField>
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
className="justify-between px-3 pr-2 system-md-regular text-text-primary"
|
||||
>
|
||||
{option.label}
|
||||
<DropdownMenuRadioItemIndicator className="ml-2" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], {
|
||||
ns: 'plugin',
|
||||
})}
|
||||
title={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], {
|
||||
ns: 'plugin',
|
||||
})}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => {
|
||||
void setSort({ sort_by: sortField, sort_order: nextSortOrder })
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={sortOrder === 'desc' ? 'i-ri-sort-desc size-4' : 'i-ri-sort-asc size-4'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedCreations.length > 0 ? (
|
||||
<div className="grid w-full grid-cols-1 gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sortedCreations.map((creation) => (
|
||||
<CreationCard
|
||||
key={creation.id}
|
||||
creation={creation}
|
||||
action={getCreationAction(creation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex w-full flex-col items-center gap-2 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
aria-busy={isLoadingMore || undefined}
|
||||
disabled={isLoadingMore}
|
||||
className="flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void loadMore()
|
||||
}}
|
||||
>
|
||||
{t(($) => $['marketplace.creatorProfile.loadMore'], { ns: 'plugin' })}
|
||||
</button>
|
||||
{loadMoreFailed && (
|
||||
<p className="system-xs-regular text-text-destructive">
|
||||
{t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import type { CreatorProfileViewModel, CreatorSocialPlatform } from './model'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Partner from '@/app/components/plugins/base/badges/partner'
|
||||
import Verified from '@/app/components/plugins/base/badges/verified'
|
||||
import PublisherAvatar from './publisher-avatar'
|
||||
|
||||
type CreatorSidebarProps = {
|
||||
profile: CreatorProfileViewModel['profile']
|
||||
}
|
||||
|
||||
function SocialIcon({ platform }: { platform: CreatorSocialPlatform }) {
|
||||
const className = 'size-4 shrink-0 text-text-tertiary'
|
||||
|
||||
if (platform === 'x') return <span aria-hidden className={cn(className, 'i-ri-twitter-x-fill')} />
|
||||
if (platform === 'instagram')
|
||||
return <span aria-hidden className={cn(className, 'i-ri-instagram-line')} />
|
||||
if (platform === 'youtube')
|
||||
return <span aria-hidden className={cn(className, 'i-ri-youtube-fill')} />
|
||||
if (platform === 'figma') return <span aria-hidden className={cn(className, 'i-ri-figma-line')} />
|
||||
if (platform === 'github')
|
||||
return <span aria-hidden className={cn(className, 'i-ri-github-fill')} />
|
||||
|
||||
return <span aria-hidden className={cn(className, 'i-ri-global-line')} />
|
||||
}
|
||||
|
||||
export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const isOrganization = profile.kind === 'organization'
|
||||
const isPartner = profile.badges.includes('partner')
|
||||
const isVerified = profile.badges.includes('verified')
|
||||
|
||||
return (
|
||||
<aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12">
|
||||
<PublisherAvatar
|
||||
avatarUrl={profile.avatarUrl}
|
||||
name={profile.displayName}
|
||||
isOrganization={isOrganization}
|
||||
size={100}
|
||||
className={cn(
|
||||
'absolute -top-12 -left-2 z-10 !size-20 border-[1.5px] border-components-panel-bg bg-background-default-dodge shadow-xs md:-top-[68px] md:!size-[100px]',
|
||||
isOrganization && 'rounded-[10px]',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<h1 className="title-2xl-semi-bold text-text-primary">{profile.displayName}</h1>
|
||||
{isOrganization && (
|
||||
<span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary uppercase">
|
||||
{t(($) => $['marketplace.creatorProfile.organization'], { ns: 'plugin' })}
|
||||
</span>
|
||||
)}
|
||||
{isPartner && (
|
||||
<Partner
|
||||
className="size-[18px] shrink-0"
|
||||
text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })}
|
||||
/>
|
||||
)}
|
||||
{isVerified && (
|
||||
<Verified
|
||||
className="size-[18px] shrink-0"
|
||||
text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="system-sm-regular text-text-tertiary">@{profile.handle}</span>
|
||||
</div>
|
||||
|
||||
{profile.description && (
|
||||
<p className="system-sm-regular whitespace-pre-wrap text-text-secondary">
|
||||
{profile.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{profile.email && (
|
||||
<a
|
||||
href={`mailto:${profile.email}`}
|
||||
className="flex min-w-0 items-center gap-1.5 py-1 system-sm-regular text-text-secondary outline-hidden hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="truncate">{profile.email}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{profile.socialLinks.length > 0 && (
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span className="shrink-0 system-xs-medium text-text-tertiary uppercase">
|
||||
{t(($) => $['marketplace.creatorProfile.onTheWeb'], { ns: 'plugin' })}
|
||||
</span>
|
||||
<div className="h-px min-w-0 flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{profile.socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-w-0 items-center gap-1.5 system-sm-regular text-text-secondary outline-hidden transition-colors hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<SocialIcon platform={link.platform} />
|
||||
<span className="truncate">{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import type { MarketplaceCreator, MarketplaceOrganization } from '@dify/contracts/marketplace'
|
||||
import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model'
|
||||
import { cache } from 'react'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { marketplaceClient } from '@/service/client'
|
||||
import { getPluginIconInMarketplace } from '../utils'
|
||||
import {
|
||||
adaptCreatorProfile,
|
||||
parseCreatorSortField,
|
||||
parseCreatorSortOrder,
|
||||
sortCreatorCreations,
|
||||
} from './model'
|
||||
import {
|
||||
fetchPublisherPluginPage,
|
||||
fetchPublisherTemplatePage,
|
||||
getDependencyIcon,
|
||||
getTemplateIcon,
|
||||
toCreatorRecords,
|
||||
} from './publisher'
|
||||
import 'server-only'
|
||||
|
||||
const mapOrganizationToCreator = (
|
||||
organization: MarketplaceOrganization,
|
||||
uniqueHandle: string,
|
||||
): MarketplaceCreator => ({
|
||||
id: organization.id || organization.name,
|
||||
email: organization.email,
|
||||
name: organization.name || organization.display_name || uniqueHandle,
|
||||
display_name: organization.display_name || organization.name || uniqueHandle,
|
||||
unique_handle: organization.unique_handle || uniqueHandle,
|
||||
display_email: organization.display_email,
|
||||
description: organization.description,
|
||||
avatar: organization.avatar,
|
||||
background_image: organization.background_image,
|
||||
social_links: organization.social_links ?? [],
|
||||
badges: organization.badges,
|
||||
verified: organization.verified,
|
||||
status: organization.status,
|
||||
created_at: organization.created_at,
|
||||
updated_at: organization.updated_at,
|
||||
})
|
||||
|
||||
const getPublisher = async (uniqueHandle: string, publisherType?: string) => {
|
||||
if (publisherType === 'organization') {
|
||||
const response = await marketplaceClient.organizationDetail({
|
||||
params: { id: uniqueHandle },
|
||||
})
|
||||
const organization = response.data?.organization
|
||||
return organization ? mapOrganizationToCreator(organization, uniqueHandle) : undefined
|
||||
}
|
||||
|
||||
const response = await marketplaceClient.creatorDetail({
|
||||
params: { uniqueHandle },
|
||||
})
|
||||
return response.data?.creator
|
||||
}
|
||||
|
||||
const loadCreatorProfileCached = cache(
|
||||
async (
|
||||
uniqueHandle: string,
|
||||
publisherType: string | undefined,
|
||||
locale: string,
|
||||
sortField: CreatorSortField,
|
||||
sortOrder: CreatorSortOrder,
|
||||
): Promise<LoadedCreatorProfile | null> => {
|
||||
const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([
|
||||
getPublisher(uniqueHandle, publisherType),
|
||||
fetchPublisherPluginPage({ uniqueHandle, page: 1, sortField, sortOrder }),
|
||||
fetchPublisherTemplatePage({ uniqueHandle, page: 1, sortField, sortOrder }),
|
||||
])
|
||||
|
||||
if (creatorResult.status === 'rejected') throw creatorResult.reason
|
||||
const creator = creatorResult.value
|
||||
if (!creator) return null
|
||||
|
||||
const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.items : []
|
||||
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value.items : []
|
||||
const pluginPage = pluginsResult.status === 'fulfilled' ? pluginsResult.value : undefined
|
||||
const templatePage = templatesResult.status === 'fulfilled' ? templatesResult.value : undefined
|
||||
const kind = publisherType === 'organization' ? 'organization' : 'individual'
|
||||
const resource = kind === 'organization' ? 'organizations' : 'creators'
|
||||
const encodedHandle = encodeURIComponent(uniqueHandle)
|
||||
const backgroundUrl = creator.background_image
|
||||
? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/background-image`
|
||||
: ''
|
||||
const avatarUrl = creator.avatar
|
||||
? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/avatar`
|
||||
: ''
|
||||
const viewModel = adaptCreatorProfile({
|
||||
creator,
|
||||
kind,
|
||||
locale,
|
||||
avatarUrl,
|
||||
backgroundUrl,
|
||||
plugins,
|
||||
templates,
|
||||
resolvePluginIcon: getPluginIconInMarketplace,
|
||||
resolveTemplateIcon: getTemplateIcon,
|
||||
resolveDependencyIcon: getDependencyIcon,
|
||||
})
|
||||
const records = toCreatorRecords({ locale, plugins, templates })
|
||||
|
||||
return {
|
||||
viewModel: {
|
||||
...viewModel,
|
||||
creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder),
|
||||
},
|
||||
pluginsByCreationId: records.pluginsByCreationId,
|
||||
templatesByCreationId: records.templatesByCreationId,
|
||||
inventory: {
|
||||
uniqueHandle,
|
||||
pluginHasMore: pluginPage?.hasMore ?? false,
|
||||
templateHasMore: templatePage?.hasMore ?? false,
|
||||
pluginNextPage: 2,
|
||||
templateNextPage: 2,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const loadCreatorProfile = ({
|
||||
uniqueHandle,
|
||||
publisherType,
|
||||
locale,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: {
|
||||
uniqueHandle: string
|
||||
publisherType?: string
|
||||
locale: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
}) =>
|
||||
loadCreatorProfileCached(
|
||||
uniqueHandle,
|
||||
publisherType,
|
||||
locale,
|
||||
parseCreatorSortField(sortBy),
|
||||
parseCreatorSortOrder(sortOrder),
|
||||
)
|
||||
@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete'
|
||||
import type { CreatorCreation, LoadedCreatorProfile } from './model'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useMemo, useState } from 'react'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import MarketplaceDetailDialog from '../detail-dialog'
|
||||
import TemplateDetailDialog from '../templates/template-detail-dialog'
|
||||
import { getFormattedPlugin } from '../utils'
|
||||
import CreatorProfileHeader from './header'
|
||||
import CreatorProfileView from './view'
|
||||
|
||||
type SelectedCreation =
|
||||
| { kind: 'plugin'; plugin: Plugin }
|
||||
| { kind: 'template'; template: MarketplaceTemplate }
|
||||
|
||||
type DifyCreatorProfileProps = {
|
||||
loadedProfile: LoadedCreatorProfile
|
||||
locale: string
|
||||
}
|
||||
|
||||
const normalizePlugin = (plugin: Plugin): Plugin => ({
|
||||
...plugin,
|
||||
label: plugin.label ?? {},
|
||||
brief: plugin.brief ?? {},
|
||||
description: plugin.description ?? {},
|
||||
tags: plugin.tags ?? [],
|
||||
badges: plugin.badges ?? null,
|
||||
})
|
||||
|
||||
export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) {
|
||||
const router = useRouter()
|
||||
const [selected, setSelected] = useState<SelectedCreation | null>(null)
|
||||
const [sourceProfile, setSourceProfile] = useState(loadedProfile)
|
||||
const [pluginsByCreationId, setPluginsByCreationId] = useState(loadedProfile.pluginsByCreationId)
|
||||
const [templatesByCreationId, setTemplatesByCreationId] = useState(
|
||||
loadedProfile.templatesByCreationId,
|
||||
)
|
||||
if (loadedProfile !== sourceProfile) {
|
||||
setSourceProfile(loadedProfile)
|
||||
setPluginsByCreationId(loadedProfile.pluginsByCreationId)
|
||||
setTemplatesByCreationId(loadedProfile.templatesByCreationId)
|
||||
}
|
||||
|
||||
const profilePlugins = Object.values(pluginsByCreationId)
|
||||
const pluginIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set([
|
||||
...profilePlugins.map((plugin) => plugin.plugin_id),
|
||||
...(selected?.kind === 'plugin' ? [selected.plugin.plugin_id] : []),
|
||||
]),
|
||||
).sort(),
|
||||
[profilePlugins, selected],
|
||||
)
|
||||
const { installedInfo } = useCheckInstalled({
|
||||
pluginIds,
|
||||
enabled: pluginIds.length > 0,
|
||||
})
|
||||
|
||||
const selectCreation = (creation: CreatorCreation) => {
|
||||
if (creation.kind === 'plugin') {
|
||||
const plugin = pluginsByCreationId[creation.id]
|
||||
if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) })
|
||||
return
|
||||
}
|
||||
|
||||
const template = templatesByCreationId[creation.id]
|
||||
if (template) setSelected({ kind: 'template', template })
|
||||
}
|
||||
|
||||
const selectSearchResult = (selection: MarketplaceSearchSelection) => {
|
||||
if (selection.kind === 'plugin') {
|
||||
setSelected({
|
||||
kind: 'plugin',
|
||||
plugin: normalizePlugin(getFormattedPlugin(selection.plugin)),
|
||||
})
|
||||
return
|
||||
}
|
||||
setSelected({ kind: 'template', template: selection.template })
|
||||
}
|
||||
|
||||
const closeSelected = () => setSelected(null)
|
||||
const selectedPlugin = selected?.kind === 'plugin' ? selected.plugin : null
|
||||
const selectedTemplate = selected?.kind === 'template' ? selected.template : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreatorProfileView
|
||||
profile={loadedProfile.viewModel}
|
||||
homeHref="/marketplace"
|
||||
isMarketplacePlatform
|
||||
inventory={loadedProfile.inventory}
|
||||
locale={locale}
|
||||
onRecordsLoaded={(records) => {
|
||||
setPluginsByCreationId((current) => ({ ...current, ...records.pluginsByCreationId }))
|
||||
setTemplatesByCreationId((current) => ({
|
||||
...current,
|
||||
...records.templatesByCreationId,
|
||||
}))
|
||||
}}
|
||||
getCreationAction={(creation) => ({
|
||||
type: 'select',
|
||||
onSelect: () => selectCreation(creation),
|
||||
})}
|
||||
header={<CreatorProfileHeader locale={locale} onSuggestionSelect={selectSearchResult} />}
|
||||
/>
|
||||
|
||||
{selectedPlugin && (
|
||||
<MarketplaceDetailDialog
|
||||
isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])}
|
||||
open
|
||||
plugin={selectedPlugin}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeSelected()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedTemplate && (
|
||||
<TemplateDetailDialog
|
||||
open
|
||||
template={selectedTemplate}
|
||||
onInstall={() => {
|
||||
closeSelected()
|
||||
router.push(`/apps?template-id=${encodeURIComponent(selectedTemplate.id)}`)
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeSelected()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Link from '@/next/link'
|
||||
import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
|
||||
import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg'
|
||||
import HomeCatalogTabs from '../home/home-catalog-tabs'
|
||||
import HomeGuide from '../home/home-guide'
|
||||
import styles from '../home/home-sticky.module.css'
|
||||
import { MarketplaceSearchAutocomplete } from '../home/marketplace-search-autocomplete'
|
||||
|
||||
type CreatorProfileHeaderProps = {
|
||||
actions?: React.ReactNode
|
||||
locale: string
|
||||
onSuggestionSelect: (selection: MarketplaceSearchSelection) => void
|
||||
}
|
||||
|
||||
export default function CreatorProfileHeader({
|
||||
actions,
|
||||
locale,
|
||||
onSuggestionSelect,
|
||||
}: CreatorProfileHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 md:px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Link
|
||||
href="/marketplace"
|
||||
aria-label="Dify Marketplace"
|
||||
className="flex h-full w-[141.933px] shrink-0 items-center"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-[16.386px] w-[141.761px] max-w-none shrink-0',
|
||||
styles.marketplaceLogoLight,
|
||||
)}
|
||||
src={MarketplaceLogo.src}
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-[16.386px] w-[141.761px] max-w-none shrink-0',
|
||||
styles.marketplaceLogoDark,
|
||||
)}
|
||||
src={MarketplaceLogoDark.src}
|
||||
/>
|
||||
</Link>
|
||||
<div className="hidden md:block">
|
||||
<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden w-80 shrink-0 md:block">
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale={locale}
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={setSearchValue}
|
||||
placeholder={t(($) => $['marketplace.creatorProfile.searchPlaceholder'], {
|
||||
ns: 'plugin',
|
||||
})}
|
||||
scope="all"
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2.5">
|
||||
<div className="hidden md:block">
|
||||
<HomeGuide isMarketplacePlatform={false} />
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
361
web/app/components/plugins/marketplace/creator-profile/model.ts
Normal file
361
web/app/components/plugins/marketplace/creator-profile/model.ts
Normal file
@ -0,0 +1,361 @@
|
||||
import type {
|
||||
MarketplaceCreator,
|
||||
MarketplacePlugin,
|
||||
MarketplaceTemplate,
|
||||
MarketplaceTimestamp,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
|
||||
type CreatorProfileKind = 'individual' | 'organization'
|
||||
type CreatorProfileBadge = 'partner' | 'verified'
|
||||
export type CreatorSocialPlatform = 'website' | 'x' | 'instagram' | 'youtube' | 'figma' | 'github'
|
||||
export type CreatorSortField = 'updatedAt' | 'createdAt' | 'popularity'
|
||||
export type CreatorSortOrder = 'asc' | 'desc'
|
||||
export const CREATOR_SORT_FIELDS = ['updatedAt', 'createdAt', 'popularity'] as const
|
||||
export const DEFAULT_CREATOR_SORT_FIELD: CreatorSortField = 'updatedAt'
|
||||
export const DEFAULT_CREATOR_SORT_ORDER: CreatorSortOrder = 'desc'
|
||||
|
||||
export const parseCreatorSortField = (value?: string | null): CreatorSortField =>
|
||||
CREATOR_SORT_FIELDS.includes(value as CreatorSortField)
|
||||
? (value as CreatorSortField)
|
||||
: DEFAULT_CREATOR_SORT_FIELD
|
||||
|
||||
export const parseCreatorSortOrder = (value?: string | null): CreatorSortOrder => {
|
||||
const normalized = value?.toLowerCase()
|
||||
return normalized === 'asc' || normalized === 'desc' ? normalized : DEFAULT_CREATOR_SORT_ORDER
|
||||
}
|
||||
|
||||
export const toPublisherSortQuery = (field: CreatorSortField, order: CreatorSortOrder) => {
|
||||
const sort_order = order === 'asc' ? 'ASC' : 'DESC'
|
||||
return {
|
||||
plugins: {
|
||||
sort_by:
|
||||
field === 'updatedAt'
|
||||
? 'version_updated_at'
|
||||
: field === 'createdAt'
|
||||
? 'created_at'
|
||||
: 'install_count',
|
||||
sort_order,
|
||||
},
|
||||
templates: {
|
||||
sort_by:
|
||||
field === 'updatedAt' ? 'updated_at' : field === 'createdAt' ? 'created_at' : 'usage_count',
|
||||
sort_order,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatorSocialLink = {
|
||||
platform: CreatorSocialPlatform
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type CreatorCreationTarget =
|
||||
| {
|
||||
type: 'plugin'
|
||||
org: string
|
||||
name: string
|
||||
pluginType: MarketplacePlugin['type']
|
||||
}
|
||||
| {
|
||||
type: 'template'
|
||||
id: string
|
||||
publisher: string
|
||||
templateName: string
|
||||
}
|
||||
|
||||
type CreatorCreationIcon =
|
||||
| { type: 'image'; src: string }
|
||||
| { type: 'emoji'; value: string; background?: string }
|
||||
|
||||
export type CreatorCreation = {
|
||||
id: string
|
||||
kind: 'plugin' | 'template'
|
||||
title: string
|
||||
description: string
|
||||
target: CreatorCreationTarget
|
||||
icon: CreatorCreationIcon
|
||||
dependencyIcons: string[]
|
||||
dependencyCount: number
|
||||
updatedAt: number
|
||||
createdAt: number
|
||||
popularity: number
|
||||
}
|
||||
|
||||
export type CreatorProfileViewModel = {
|
||||
profile: {
|
||||
kind: CreatorProfileKind
|
||||
displayName: string
|
||||
handle: string
|
||||
description?: string
|
||||
email?: string
|
||||
avatarUrl: string
|
||||
backgroundUrl: string
|
||||
badges: CreatorProfileBadge[]
|
||||
socialLinks: CreatorSocialLink[]
|
||||
}
|
||||
creations: CreatorCreation[]
|
||||
}
|
||||
|
||||
export type CreatorInventory = {
|
||||
uniqueHandle: string
|
||||
pluginHasMore: boolean
|
||||
templateHasMore: boolean
|
||||
pluginNextPage: number
|
||||
templateNextPage: number
|
||||
}
|
||||
|
||||
export type LoadedCreatorProfile = {
|
||||
viewModel: CreatorProfileViewModel
|
||||
pluginsByCreationId: Record<string, Plugin>
|
||||
templatesByCreationId: Record<string, MarketplaceTemplate>
|
||||
inventory: CreatorInventory
|
||||
}
|
||||
|
||||
export type CreatorCreationAction =
|
||||
| { type: 'link'; href: string }
|
||||
| { type: 'select'; onSelect: () => void }
|
||||
|
||||
export type CreatorProfileAdapterInput = {
|
||||
creator: MarketplaceCreator
|
||||
kind: CreatorProfileKind
|
||||
locale: string
|
||||
avatarUrl: string
|
||||
backgroundUrl: string
|
||||
plugins: MarketplacePlugin[]
|
||||
templates: MarketplaceTemplate[]
|
||||
resolvePluginIcon: (plugin: MarketplacePlugin) => string
|
||||
resolveTemplateIcon: (template: MarketplaceTemplate) => string
|
||||
resolveDependencyIcon: (pluginId: string) => string
|
||||
}
|
||||
|
||||
const toTimestamp = (value?: MarketplaceTimestamp | null) => {
|
||||
if (value === undefined || value === null || value === '') return 0
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
|
||||
// Marketplace search responses use Unix seconds, while some consumers may already
|
||||
// provide JavaScript timestamps in milliseconds.
|
||||
return Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value)
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp
|
||||
}
|
||||
|
||||
const firstLocalizedString = (value: object, keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
const entry = (value as Record<string, unknown>)[key]
|
||||
if (typeof entry === 'string' && entry) return entry
|
||||
}
|
||||
return (
|
||||
Object.values(value).find((entry): entry is string => typeof entry === 'string' && !!entry) ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const getCreatorLocalizedText = (
|
||||
value: Partial<Record<string, string>> | string | undefined,
|
||||
locale: string,
|
||||
) => {
|
||||
if (typeof value === 'string') return value
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
|
||||
const normalizedLocale = locale.replace('-', '_')
|
||||
return firstLocalizedString(value, [locale, normalizedLocale, 'en-US', 'en_US'])
|
||||
}
|
||||
|
||||
const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
|
||||
if (
|
||||
hostname === 'x.com' ||
|
||||
hostname.endsWith('.x.com') ||
|
||||
hostname === 'twitter.com' ||
|
||||
hostname.endsWith('.twitter.com')
|
||||
)
|
||||
return 'x'
|
||||
if (hostname === 'instagram.com' || hostname.endsWith('.instagram.com')) return 'instagram'
|
||||
if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com') || hostname === 'youtu.be')
|
||||
return 'youtube'
|
||||
if (hostname === 'figma.com' || hostname.endsWith('.figma.com')) return 'figma'
|
||||
if (hostname === 'github.com' || hostname.endsWith('.github.com')) return 'github'
|
||||
return 'website'
|
||||
}
|
||||
|
||||
export const normalizeCreatorSocialLink = (value: unknown): CreatorSocialLink | null => {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmedValue = value.trim()
|
||||
if (!trimmedValue) return null
|
||||
|
||||
const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(trimmedValue)
|
||||
if (hasScheme && !/^https?:\/\//i.test(trimmedValue)) return null
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
/^https?:\/\//i.test(trimmedValue) ? trimmedValue : `https://${trimmedValue}`,
|
||||
)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
|
||||
const hostname = url.hostname.toLowerCase().replace(/^www\./, '')
|
||||
return {
|
||||
platform: getSocialPlatform(hostname),
|
||||
href: url.toString(),
|
||||
label: trimmedValue.replace(/^https?:\/\//i, '').replace(/\/$/, ''),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getCreatorBadges = (creator: MarketplaceCreator) => {
|
||||
const badges = new Set<CreatorProfileBadge>()
|
||||
if (creator.badges?.includes('partner')) badges.add('partner')
|
||||
if (creator.verified || creator.badges?.includes('verified')) badges.add('verified')
|
||||
return Array.from(badges)
|
||||
}
|
||||
|
||||
export const adaptCreations = ({
|
||||
locale,
|
||||
plugins,
|
||||
templates,
|
||||
resolvePluginIcon,
|
||||
resolveTemplateIcon,
|
||||
resolveDependencyIcon,
|
||||
}: Pick<
|
||||
CreatorProfileAdapterInput,
|
||||
| 'locale'
|
||||
| 'plugins'
|
||||
| 'templates'
|
||||
| 'resolvePluginIcon'
|
||||
| 'resolveTemplateIcon'
|
||||
| 'resolveDependencyIcon'
|
||||
>): CreatorCreation[] => {
|
||||
const pluginCreations = plugins.map((plugin): CreatorCreation => ({
|
||||
id: `${plugin.type}:${plugin.org}/${plugin.name}`,
|
||||
kind: 'plugin',
|
||||
title: getCreatorLocalizedText(plugin.labels ?? plugin.label, locale) || plugin.name,
|
||||
description:
|
||||
getCreatorLocalizedText(
|
||||
plugin.type === 'bundle' ? plugin.description : plugin.brief,
|
||||
locale,
|
||||
) ||
|
||||
plugin.introduction ||
|
||||
'',
|
||||
target: {
|
||||
type: 'plugin',
|
||||
org: plugin.org,
|
||||
name: plugin.name,
|
||||
pluginType: plugin.type,
|
||||
},
|
||||
icon: { type: 'image', src: resolvePluginIcon(plugin) },
|
||||
dependencyIcons: [],
|
||||
dependencyCount: 0,
|
||||
updatedAt: toTimestamp(plugin.version_updated_at || plugin.updated_at),
|
||||
createdAt: toTimestamp(plugin.created_at),
|
||||
popularity: plugin.install_count || 0,
|
||||
}))
|
||||
|
||||
const templateCreations = templates.map((template): CreatorCreation => {
|
||||
const templateIcon = resolveTemplateIcon(template)
|
||||
const dependencyIds = (template.deps_plugins ?? []).filter(
|
||||
(id): id is string => typeof id === 'string' && id.length > 0,
|
||||
)
|
||||
const publisher =
|
||||
template.publisher_handle ||
|
||||
template.publisher_unique_handle ||
|
||||
template.creator_email ||
|
||||
'template'
|
||||
|
||||
return {
|
||||
id: `template:${template.id}`,
|
||||
kind: 'template',
|
||||
title: template.template_name,
|
||||
description: template.overview || '',
|
||||
target: {
|
||||
type: 'template',
|
||||
id: template.id,
|
||||
publisher,
|
||||
templateName: template.template_name,
|
||||
},
|
||||
icon: templateIcon
|
||||
? { type: 'image', src: templateIcon }
|
||||
: { type: 'emoji', value: template.icon || '📄', background: template.icon_background },
|
||||
dependencyIcons: dependencyIds.map(resolveDependencyIcon),
|
||||
dependencyCount: dependencyIds.length,
|
||||
updatedAt: toTimestamp(template.updated_at),
|
||||
createdAt: toTimestamp(template.created_at),
|
||||
popularity: template.usage_count || 0,
|
||||
}
|
||||
})
|
||||
|
||||
return [...pluginCreations, ...templateCreations]
|
||||
}
|
||||
|
||||
export const adaptCreatorProfile = ({
|
||||
creator,
|
||||
kind,
|
||||
locale,
|
||||
avatarUrl,
|
||||
backgroundUrl,
|
||||
plugins,
|
||||
templates,
|
||||
resolvePluginIcon,
|
||||
resolveTemplateIcon,
|
||||
resolveDependencyIcon,
|
||||
}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
|
||||
return {
|
||||
profile: {
|
||||
kind,
|
||||
displayName: creator.display_name || creator.name || creator.unique_handle,
|
||||
handle: creator.unique_handle,
|
||||
description: creator.description || undefined,
|
||||
email: creator.display_email || creator.email || undefined,
|
||||
avatarUrl,
|
||||
backgroundUrl,
|
||||
badges: getCreatorBadges(creator),
|
||||
socialLinks: (creator.social_links ?? [])
|
||||
.map(normalizeCreatorSocialLink)
|
||||
.filter((link): link is CreatorSocialLink => link !== null),
|
||||
},
|
||||
creations: adaptCreations({
|
||||
locale,
|
||||
plugins,
|
||||
templates,
|
||||
resolvePluginIcon,
|
||||
resolveTemplateIcon,
|
||||
resolveDependencyIcon,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const sortCreatorCreations = (
|
||||
creations: CreatorCreation[],
|
||||
field: CreatorSortField,
|
||||
order: CreatorSortOrder,
|
||||
) => {
|
||||
const direction = order === 'asc' ? 1 : -1
|
||||
return creations
|
||||
.map((creation, index) => ({ creation, index }))
|
||||
.sort((left, right) => {
|
||||
const difference = (left.creation[field] - right.creation[field]) * direction
|
||||
return difference || left.index - right.index
|
||||
})
|
||||
.map(({ creation }) => creation)
|
||||
}
|
||||
|
||||
export const getStandaloneCreationHref = (creation: CreatorCreation, locale?: string) => {
|
||||
const language = locale ? `language=${encodeURIComponent(locale)}` : ''
|
||||
if (creation.target.type === 'plugin') {
|
||||
const resource = creation.target.pluginType === 'bundle' ? 'bundles' : 'plugin'
|
||||
const path = `/${resource}/${encodeURIComponent(creation.target.org)}/${encodeURIComponent(creation.target.name)}`
|
||||
return language ? `${path}?${language}` : path
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
templateId: creation.target.id,
|
||||
creationType: 'templates',
|
||||
})
|
||||
if (locale) params.set('language', locale)
|
||||
return `/template/${encodeURIComponent(creation.target.publisher)}/${encodeURIComponent(creation.target.templateName)}?${params.toString()}`
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useState } from 'react'
|
||||
|
||||
type PublisherAvatarProps = {
|
||||
avatarUrl: string
|
||||
name: string
|
||||
isOrganization: boolean
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Keep in sync with Creator Center `components/ui/avatar.tsx`.
|
||||
const DEFAULT_AVATAR_BG =
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%), linear-gradient(90deg, #155aef 0%, #155aef 100%)'
|
||||
|
||||
const DEFAULT_AVATAR_LETTER_STYLE: CSSProperties = {
|
||||
color: '#FFFFFF',
|
||||
textShadow: '0px 0.25px 0.5px rgba(0, 0, 0, 0.20)',
|
||||
lineHeight: '120%',
|
||||
textTransform: 'uppercase',
|
||||
}
|
||||
|
||||
function getFallbackTextClass(size: number) {
|
||||
if (size <= 32) return 'text-xs'
|
||||
if (size <= 50) return 'text-base'
|
||||
return 'text-[40px]'
|
||||
}
|
||||
|
||||
export default function PublisherAvatar({
|
||||
avatarUrl,
|
||||
name,
|
||||
isOrganization,
|
||||
size = 24,
|
||||
className,
|
||||
}: PublisherAvatarProps) {
|
||||
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null)
|
||||
const shapeClass = isOrganization ? 'rounded-md' : 'rounded-full'
|
||||
const shouldShowImage = Boolean(avatarUrl) && failedAvatarUrl !== avatarUrl
|
||||
const fallbackLetter = name?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
className={cn(
|
||||
'relative shrink-0 overflow-hidden border-[0.5px] border-divider-regular',
|
||||
shapeClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shouldShowImage ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
className={cn('size-full object-cover', shapeClass)}
|
||||
onError={() => setFailedAvatarUrl(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn('flex size-full items-center justify-center', shapeClass)}
|
||||
style={{ background: DEFAULT_AVATAR_BG }}
|
||||
>
|
||||
<span
|
||||
className={cn(getFallbackTextClass(size), 'font-semibold')}
|
||||
style={DEFAULT_AVATAR_LETTER_STYLE}
|
||||
>
|
||||
{fallbackLetter}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { CreatorCreation, CreatorSortField, CreatorSortOrder } from './model'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { marketplaceClient } from '@/service/client'
|
||||
import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
|
||||
import { adaptCreations, toPublisherSortQuery } from './model'
|
||||
|
||||
const CREATOR_PAGE_SIZE = 40
|
||||
|
||||
export type PublisherPage<T> = {
|
||||
items: T[]
|
||||
total?: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
const publisherPageHasMore = (page: number, itemCount: number, total?: number) =>
|
||||
typeof total === 'number' ? page * CREATOR_PAGE_SIZE < total : itemCount === CREATOR_PAGE_SIZE
|
||||
|
||||
export const getTemplateIcon = (template: MarketplaceTemplate) =>
|
||||
template.icon_file_key
|
||||
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
|
||||
: ''
|
||||
|
||||
export const getDependencyIcon = (pluginId: string) => {
|
||||
if (!pluginId.includes('/')) return ''
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
|
||||
}
|
||||
|
||||
export async function fetchPublisherPluginPage({
|
||||
uniqueHandle,
|
||||
page,
|
||||
sortField,
|
||||
sortOrder,
|
||||
}: {
|
||||
uniqueHandle: string
|
||||
page: number
|
||||
sortField: CreatorSortField
|
||||
sortOrder: CreatorSortOrder
|
||||
}): Promise<PublisherPage<MarketplacePlugin>> {
|
||||
const { plugins } = toPublisherSortQuery(sortField, sortOrder)
|
||||
const response = await marketplaceClient.publisherPlugins({
|
||||
params: { uniqueHandle },
|
||||
query: { page, page_size: CREATOR_PAGE_SIZE, ...plugins },
|
||||
})
|
||||
const items = response.data?.plugins ?? []
|
||||
const total = response.data?.total
|
||||
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
|
||||
}
|
||||
|
||||
export async function fetchPublisherTemplatePage({
|
||||
uniqueHandle,
|
||||
page,
|
||||
sortField,
|
||||
sortOrder,
|
||||
}: {
|
||||
uniqueHandle: string
|
||||
page: number
|
||||
sortField: CreatorSortField
|
||||
sortOrder: CreatorSortOrder
|
||||
}): Promise<PublisherPage<MarketplaceTemplate>> {
|
||||
const { templates } = toPublisherSortQuery(sortField, sortOrder)
|
||||
const response = await marketplaceClient.publisherTemplates({
|
||||
params: { uniqueHandle },
|
||||
query: { page, page_size: CREATOR_PAGE_SIZE, ...templates },
|
||||
})
|
||||
const items = response.data?.templates ?? []
|
||||
const total = response.data?.total
|
||||
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
|
||||
}
|
||||
|
||||
export const toCreatorRecords = ({
|
||||
locale,
|
||||
plugins,
|
||||
templates,
|
||||
}: {
|
||||
locale: string
|
||||
plugins: MarketplacePlugin[]
|
||||
templates: MarketplaceTemplate[]
|
||||
}): {
|
||||
creations: CreatorCreation[]
|
||||
pluginsByCreationId: Record<string, Plugin>
|
||||
templatesByCreationId: Record<string, MarketplaceTemplate>
|
||||
} => ({
|
||||
creations: adaptCreations({
|
||||
locale,
|
||||
plugins,
|
||||
templates,
|
||||
resolvePluginIcon: getPluginIconInMarketplace,
|
||||
resolveTemplateIcon: getTemplateIcon,
|
||||
resolveDependencyIcon: getDependencyIcon,
|
||||
}),
|
||||
pluginsByCreationId: Object.fromEntries(
|
||||
plugins.map((plugin) => [
|
||||
`${plugin.type}:${plugin.org}/${plugin.name}`,
|
||||
getFormattedPlugin(plugin),
|
||||
]),
|
||||
),
|
||||
templatesByCreationId: Object.fromEntries(
|
||||
templates.map((template) => [`template:${template.id}`, template]),
|
||||
),
|
||||
})
|
||||
110
web/app/components/plugins/marketplace/creator-profile/view.tsx
Normal file
110
web/app/components/plugins/marketplace/creator-profile/view.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
CreatorCreation,
|
||||
CreatorCreationAction,
|
||||
CreatorInventory,
|
||||
CreatorProfileViewModel,
|
||||
} from './model'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Link from '@/next/link'
|
||||
import DefaultCreatorBackground from './assets/default-background.png'
|
||||
import CreatorContent from './creator-content'
|
||||
import CreatorSidebar from './creator-sidebar'
|
||||
|
||||
export type CreatorProfileViewProps = {
|
||||
profile: CreatorProfileViewModel
|
||||
getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
|
||||
header?: ReactNode
|
||||
homeHref: string
|
||||
isMarketplacePlatform: boolean
|
||||
inventory?: CreatorInventory
|
||||
locale?: string
|
||||
onRecordsLoaded?: (records: {
|
||||
pluginsByCreationId: Record<string, Plugin>
|
||||
templatesByCreationId: Record<string, MarketplaceTemplate>
|
||||
}) => void
|
||||
}
|
||||
|
||||
export default function CreatorProfileView({
|
||||
profile,
|
||||
getCreationAction,
|
||||
header,
|
||||
homeHref,
|
||||
isMarketplacePlatform,
|
||||
inventory,
|
||||
locale,
|
||||
onRecordsLoaded,
|
||||
}: CreatorProfileViewProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full shrink-0 flex-col bg-background-default">
|
||||
{header}
|
||||
<main
|
||||
className={cn(
|
||||
'flex w-full flex-1 flex-col px-4',
|
||||
isMarketplacePlatform ? 'md:px-6' : 'md:px-9',
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
aria-label={t(($) => $['marketplace.creatorProfile.breadcrumbLabel'], { ns: 'plugin' })}
|
||||
className="flex h-12 shrink-0 items-end gap-2 overflow-hidden"
|
||||
>
|
||||
<Link
|
||||
href={homeHref}
|
||||
aria-label={t(($) => $['marketplace.creatorProfile.home'], { ns: 'plugin' })}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-home-4-line size-4" />
|
||||
</Link>
|
||||
<span aria-hidden className="pb-0.5 system-md-regular text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
<span className="pb-0.5 system-md-regular text-text-primary">
|
||||
{t(($) => $['marketplace.creatorProfile.title'], { ns: 'plugin' })}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="w-full pt-5 pb-8">
|
||||
<div
|
||||
className="relative h-40 w-full overflow-hidden rounded-xl border-0 bg-cover bg-center bg-no-repeat md:h-60"
|
||||
style={{ backgroundImage: `url("${DefaultCreatorBackground.src}")` }}
|
||||
>
|
||||
{profile.profile.backgroundUrl && (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
src={profile.profile.backgroundUrl}
|
||||
className="size-full border-0 object-cover object-center"
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 grid-cols-1 gap-8 md:grid-cols-[234px_minmax(0,1fr)]',
|
||||
isMarketplacePlatform ? 'md:pl-4' : 'md:pl-9',
|
||||
)}
|
||||
>
|
||||
<CreatorSidebar profile={profile.profile} />
|
||||
<CreatorContent
|
||||
creations={profile.creations}
|
||||
getCreationAction={getCreationAction}
|
||||
inventory={inventory}
|
||||
locale={locale}
|
||||
onRecordsLoaded={onRecordsLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { useLocale, useTranslation } from '#i18n'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
|
||||
import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '../constants'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
import SearchBoxWrapper from '../search-box/search-box-wrapper'
|
||||
|
||||
@ -27,7 +28,7 @@ const EXPANDED_TABS_MARGIN_TOP = 32
|
||||
const Description = ({
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
scrollContainerId = MARKETPLACE_CONTAINER_ID,
|
||||
}: DescriptionProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
|
||||
@ -0,0 +1,251 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import MarketplaceDetailDialog from '../index'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
install: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
getPluginLinkInMarketplace: (
|
||||
plugin: Plugin,
|
||||
params: {
|
||||
canInstall?: string
|
||||
installed: string
|
||||
language: string
|
||||
source?: string
|
||||
theme?: string
|
||||
view: string
|
||||
},
|
||||
) =>
|
||||
`about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}&canInstall=${params.canInstall}`,
|
||||
}))
|
||||
|
||||
vi.mock('../use-silent-install', () => ({
|
||||
useSilentMarketplaceInstall: () => ({ install: mocks.install }),
|
||||
}))
|
||||
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'plugin-a',
|
||||
plugin_id: 'plugin-a',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg',
|
||||
icon: 'icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Plugin A' },
|
||||
brief: { 'en-US': 'Brief' },
|
||||
description: { 'en-US': 'Description' },
|
||||
introduction: 'Intro',
|
||||
repository: 'https://github.com/dify/plugin-a',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 42,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
} as Plugin
|
||||
|
||||
describe('MarketplaceDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.install.mockResolvedValue({ status: 'success' })
|
||||
})
|
||||
|
||||
it('renders the marketplace detail route in modal mode and closes in place', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ThemeProvider forcedTheme="dark">
|
||||
<MarketplaceDetailDialog open isInstalled plugin={plugin} onOpenChange={onOpenChange} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
|
||||
const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail')
|
||||
expect(frame).toHaveAttribute(
|
||||
'src',
|
||||
// resolvedTheme maps the "system" preference to the concrete value, so
|
||||
// the embedded detail page receives light/dark rather than "system".
|
||||
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=true',
|
||||
)
|
||||
expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('installs from the embedded detail frame without opening a confirmation dialog', async () => {
|
||||
render(
|
||||
<ThemeProvider forcedTheme="dark">
|
||||
<MarketplaceDetailDialog open isInstalled={false} plugin={plugin} onOpenChange={vi.fn()} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
|
||||
const frame = screen.getByTitle(
|
||||
'Plugin A · plugin.detailPanel.operation.detail',
|
||||
) as HTMLIFrameElement
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
|
||||
const installRequest = {
|
||||
type: 'dify-marketplace:install-plugin',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
}
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: installRequest,
|
||||
origin: 'https://attacker.example',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
...installRequest,
|
||||
pluginUniqueIdentifier: 'another/plugin:1.0.0',
|
||||
},
|
||||
origin: 'null',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: installRequest,
|
||||
origin: 'null',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.install).toHaveBeenCalledOnce()
|
||||
expect(mocks.install).toHaveBeenCalledWith(plugin)
|
||||
expect(screen.queryByRole('dialog', { name: 'plugin.installModal.installPlugin' })).toBeNull()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'dify-marketplace:install-plugin-status',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
status: 'success',
|
||||
},
|
||||
'null',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not install when the workspace lacks plugin.install', async () => {
|
||||
render(
|
||||
<PluginInstallPermissionProvider canInstallPlugin={false}>
|
||||
<ThemeProvider forcedTheme="dark">
|
||||
<MarketplaceDetailDialog
|
||||
open
|
||||
isInstalled={false}
|
||||
plugin={plugin}
|
||||
onOpenChange={vi.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PluginInstallPermissionProvider>,
|
||||
)
|
||||
|
||||
const frame = screen.getByTitle(
|
||||
'Plugin A · plugin.detailPanel.operation.detail',
|
||||
) as HTMLIFrameElement
|
||||
expect(frame).toHaveAttribute(
|
||||
'src',
|
||||
'about:blank?plugin=dify/plugin-a&installed=false&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=false',
|
||||
)
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'dify-marketplace:install-plugin',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
},
|
||||
origin: 'null',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'dify-marketplace:install-plugin-status',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
status: 'failed',
|
||||
},
|
||||
'null',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a late install result after the timeout has already settled', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishInstall: ((result: { status: 'success' }) => void) | undefined
|
||||
mocks.install.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishInstall = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
render(
|
||||
<ThemeProvider forcedTheme="dark">
|
||||
<MarketplaceDetailDialog
|
||||
open
|
||||
isInstalled={false}
|
||||
plugin={plugin}
|
||||
onOpenChange={vi.fn()}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
|
||||
const frame = screen.getByTitle(
|
||||
'Plugin A · plugin.detailPanel.operation.detail',
|
||||
) as HTMLIFrameElement
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'dify-marketplace:install-plugin',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
},
|
||||
origin: 'null',
|
||||
source: frame.contentWindow,
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'dify-marketplace:install-plugin-status',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
status: 'timeout',
|
||||
},
|
||||
'null',
|
||||
)
|
||||
|
||||
finishInstall?.({ status: 'success' })
|
||||
await Promise.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,110 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { useSilentMarketplaceInstall } from '../use-silent-install'
|
||||
|
||||
const mockInstallPackageFromMarketPlace = vi.fn()
|
||||
const mockRefreshPluginList = vi.fn()
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({
|
||||
mutateAsync: mockInstallPackageFromMarketPlace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
|
||||
default: () => ({
|
||||
check: mockCheckTaskStatus,
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const plugin = {
|
||||
type: 'plugin',
|
||||
org: 'dify',
|
||||
name: 'plugin-a',
|
||||
plugin_id: 'dify/plugin-a',
|
||||
latest_package_identifier: 'dify/plugin-a:1.0.0@pkg',
|
||||
category: PluginCategoryEnum.tool,
|
||||
} as Plugin
|
||||
|
||||
describe('useSilentMarketplaceInstall', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: true,
|
||||
task_id: 'task-1',
|
||||
})
|
||||
mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success })
|
||||
})
|
||||
|
||||
it('installs immediately when the marketplace package is already fully installed', async () => {
|
||||
const { result } = renderHook(() => useSilentMarketplaceInstall())
|
||||
|
||||
await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' })
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith(plugin.latest_package_identifier)
|
||||
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin)
|
||||
})
|
||||
|
||||
it('waits for the install task instead of showing a confirmation step', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-2',
|
||||
})
|
||||
const { result } = renderHook(() => useSilentMarketplaceInstall())
|
||||
|
||||
await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' })
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledWith({
|
||||
taskId: 'task-2',
|
||||
pluginUniqueIdentifier: plugin.latest_package_identifier,
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin)
|
||||
})
|
||||
|
||||
it('returns the task error when installation fails', async () => {
|
||||
mockInstallPackageFromMarketPlace.mockResolvedValue({
|
||||
all_installed: false,
|
||||
task_id: 'task-3',
|
||||
})
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
status: TaskStatus.failed,
|
||||
error: 'Package not found',
|
||||
})
|
||||
const { result } = renderHook(() => useSilentMarketplaceInstall())
|
||||
|
||||
await expect(result.current.install(plugin)).resolves.toEqual({
|
||||
status: 'failed',
|
||||
error: 'Package not found',
|
||||
})
|
||||
expect(mockRefreshPluginList).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses an in-flight install instead of starting a second package request', async () => {
|
||||
let resolveInstall: ((value: { all_installed: boolean; task_id: string }) => void) | undefined
|
||||
mockInstallPackageFromMarketPlace.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInstall = resolve
|
||||
}),
|
||||
)
|
||||
const { result } = renderHook(() => useSilentMarketplaceInstall())
|
||||
|
||||
const first = result.current.install(plugin)
|
||||
const second = result.current.install(plugin)
|
||||
|
||||
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveInstall?.({ all_installed: true, task_id: 'task-4' })
|
||||
await expect(first).resolves.toEqual({ status: 'success' })
|
||||
await expect(second).resolves.toEqual({ status: 'success' })
|
||||
})
|
||||
})
|
||||
})
|
||||
137
web/app/components/plugins/marketplace/detail-dialog/frame.tsx
Normal file
137
web/app/components/plugins/marketplace/detail-dialog/frame.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogClose,
|
||||
DialogPopup,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
|
||||
type ReplyToMarketplaceFrame = (data: unknown) => void
|
||||
|
||||
type MarketplaceDetailDialogFrameProps = {
|
||||
open: boolean
|
||||
src: string
|
||||
title: string
|
||||
onMessage?: (data: unknown, reply: ReplyToMarketplaceFrame) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// The iframe load event can be delayed indefinitely on a stalled connection
|
||||
// (and cross-origin load errors are not observable), so reveal the frame after
|
||||
// this timeout instead of keeping the skeleton up forever.
|
||||
const LOADING_REVEAL_TIMEOUT_MS = 15_000
|
||||
|
||||
export default function MarketplaceDetailDialogFrame({
|
||||
open,
|
||||
src,
|
||||
title,
|
||||
onMessage,
|
||||
onOpenChange,
|
||||
}: MarketplaceDetailDialogFrameProps) {
|
||||
const { t } = useTranslation()
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [open, src])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !onMessage) return
|
||||
|
||||
const marketplaceOrigin = new URL(src, window.location.href).origin
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin)
|
||||
return
|
||||
|
||||
onMessage(event.data, (payload) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(payload, marketplaceOrigin)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [onMessage, open, src])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) setIsLoading(true)
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogBackdrop />
|
||||
{/* Keep initial focus on the visible close control: while the iframe is
|
||||
still loading it is inert, so default focus could otherwise land on
|
||||
an invisible cross-origin frame. */}
|
||||
<DialogPopup
|
||||
initialFocus={closeButtonRef}
|
||||
className="fixed top-1/2 left-1/2 h-[min(800px,calc(100dvh-48px))] w-[min(1200px,calc(100vw-48px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden border-0 p-0 shadow-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-0 bg-background-default transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-[52px] items-center px-6">
|
||||
<div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="hidden w-60 flex-col gap-4 lg:flex">
|
||||
<div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
// While loading, remove the invisible frame from focus, pointer,
|
||||
// and accessibility interaction until its content is presentable.
|
||||
inert={isLoading}
|
||||
className={cn(
|
||||
'size-full border-0 bg-background-default transition-opacity',
|
||||
isLoading ? 'pointer-events-none opacity-0' : 'opacity-100',
|
||||
)}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
src={src}
|
||||
title={title}
|
||||
/>
|
||||
<DialogClose
|
||||
render={
|
||||
<IconButton
|
||||
ref={closeButtonRef}
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="sm"
|
||||
className="absolute top-5 right-5 z-10 size-8 rounded-lg"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
161
web/app/components/plugins/marketplace/detail-dialog/index.tsx
Normal file
161
web/app/components/plugins/marketplace/detail-dialog/index.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
|
||||
import { getPluginLinkInMarketplace } from '../utils'
|
||||
import MarketplaceDetailDialogFrame from './frame'
|
||||
import { useSilentMarketplaceInstall } from './use-silent-install'
|
||||
|
||||
const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin'
|
||||
const MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE = 'dify-marketplace:install-plugin-status'
|
||||
const SILENT_INSTALL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
type MarketplaceDetailDialogProps = {
|
||||
isInstalled: boolean
|
||||
open: boolean
|
||||
plugin: Plugin
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const isInstallRequest = (data: unknown, pluginUniqueIdentifier: string) => {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'type' in data &&
|
||||
'pluginUniqueIdentifier' in data &&
|
||||
data.type === MARKETPLACE_INSTALL_MESSAGE_TYPE &&
|
||||
data.pluginUniqueIdentifier === pluginUniqueIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
function OpenMarketplaceDetailDialog({
|
||||
canInstallPlugin,
|
||||
onOpenChange,
|
||||
plugin,
|
||||
src,
|
||||
title,
|
||||
}: {
|
||||
canInstallPlugin: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
plugin: Plugin
|
||||
src: string
|
||||
title: string
|
||||
}) {
|
||||
const { install } = useSilentMarketplaceInstall()
|
||||
const timeoutIdsRef = useRef(new Set<number>())
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id))
|
||||
timeoutIdsRef.current.clear()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(data: unknown, reply: (payload: unknown) => void) => {
|
||||
if (!isInstallRequest(data, plugin.latest_package_identifier)) return
|
||||
|
||||
const uniqueIdentifier = plugin.latest_package_identifier
|
||||
if (!canInstallPlugin) {
|
||||
reply({
|
||||
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
status: 'failed',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const settle = (payload: Record<string, unknown>) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
reply(payload)
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
timeoutIdsRef.current.delete(timeoutId)
|
||||
settle({
|
||||
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
status: 'timeout',
|
||||
})
|
||||
}, SILENT_INSTALL_TIMEOUT_MS)
|
||||
timeoutIdsRef.current.add(timeoutId)
|
||||
|
||||
void install(plugin).then((result) => {
|
||||
window.clearTimeout(timeoutId)
|
||||
timeoutIdsRef.current.delete(timeoutId)
|
||||
settle({
|
||||
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
...result,
|
||||
})
|
||||
})
|
||||
},
|
||||
[canInstallPlugin, install, plugin],
|
||||
)
|
||||
|
||||
return (
|
||||
<MarketplaceDetailDialogFrame
|
||||
open
|
||||
src={src}
|
||||
title={title}
|
||||
onMessage={handleMessage}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketplaceDetailDialog({
|
||||
isInstalled,
|
||||
open,
|
||||
plugin,
|
||||
onOpenChange,
|
||||
}: MarketplaceDetailDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const { canInstallPlugin } = useOptionalPluginInstallPermission()
|
||||
// resolvedTheme maps the "system" preference to the concrete light/dark
|
||||
// value the marketplace page expects.
|
||||
const { resolvedTheme } = useTheme()
|
||||
const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
|
||||
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
|
||||
const installedForSrcRef = useRef(isInstalled)
|
||||
if (!open) installedForSrcRef.current = isInstalled
|
||||
const detailURL = getPluginLinkInMarketplace(plugin, {
|
||||
canInstall: String(canInstallPlugin),
|
||||
installed: String(installedForSrcRef.current),
|
||||
language: locale,
|
||||
source: globalThis.location?.origin,
|
||||
theme: resolvedTheme,
|
||||
view: 'modal',
|
||||
})
|
||||
const title = `${pluginLabel} · ${detailLabel}`
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<MarketplaceDetailDialogFrame
|
||||
open={false}
|
||||
src={detailURL}
|
||||
title={title}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OpenMarketplaceDetailDialog
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
plugin={plugin}
|
||||
src={detailURL}
|
||||
title={title}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarketplaceDetailDialog
|
||||
@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useCallback } from 'react'
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
|
||||
export type SilentMarketplaceInstallResult =
|
||||
| { status: 'failed'; error?: string }
|
||||
| { status: 'success' }
|
||||
|
||||
const inFlightInstalls = new Map<string, Promise<SilentMarketplaceInstallResult>>()
|
||||
|
||||
const toErrorMessage = (error: unknown) => {
|
||||
if (typeof error === 'string' && error) return error
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const useSilentMarketplaceInstall = () => {
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
|
||||
const install = useCallback(
|
||||
(plugin: Plugin) => {
|
||||
const uniqueIdentifier = plugin.latest_package_identifier
|
||||
const inFlight = inFlightInstalls.get(uniqueIdentifier)
|
||||
if (inFlight) return inFlight
|
||||
|
||||
const pending = (async (): Promise<SilentMarketplaceInstallResult> => {
|
||||
try {
|
||||
const response = await installPackageFromMarketPlace(uniqueIdentifier)
|
||||
if (response.all_installed) {
|
||||
refreshPluginList(plugin)
|
||||
return { status: 'success' }
|
||||
}
|
||||
if (!response.task_id) return { status: 'failed' }
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const { status, error } = await check({
|
||||
taskId: response.task_id,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
})
|
||||
if (status === TaskStatus.failed) return { status: 'failed', error }
|
||||
|
||||
refreshPluginList(plugin)
|
||||
return { status: 'success' }
|
||||
} catch (error) {
|
||||
return { status: 'failed', error: toErrorMessage(error) }
|
||||
}
|
||||
})().finally(() => {
|
||||
inFlightInstalls.delete(uniqueIdentifier)
|
||||
})
|
||||
|
||||
inFlightInstalls.set(uniqueIdentifier, pending)
|
||||
return pending
|
||||
},
|
||||
[installPackageFromMarketPlace, refreshPluginList],
|
||||
)
|
||||
|
||||
return { install }
|
||||
}
|
||||
46
web/app/components/plugins/marketplace/embedded.tsx
Normal file
46
web/app/components/plugins/marketplace/embedded.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { MarketplaceViewProps } from './view'
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useResetMarketplaceSearchModeOnMount } from './atoms'
|
||||
import { fetchPluginBanners } from './home/banners'
|
||||
import { MarketplaceView } from './view'
|
||||
|
||||
const BANNER_STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
export type EmbeddedMarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & {
|
||||
initialBanners?: PluginBanner[]
|
||||
/**
|
||||
* Locale used to fetch `initialBanners` during server rendering. `initialBanners`
|
||||
* is only applied while the client locale still matches it, so a client-side
|
||||
* language change refetches banners instead of seeding the new locale's cache
|
||||
* with banners from the previous language.
|
||||
*/
|
||||
initialLocale?: string
|
||||
}
|
||||
|
||||
export function EmbeddedMarketplace({
|
||||
initialBanners,
|
||||
initialLocale,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: EmbeddedMarketplaceProps) {
|
||||
useResetMarketplaceSearchModeOnMount()
|
||||
const locale = useLocale()
|
||||
const { data: banners = [] } = useQuery(
|
||||
queryOptions({
|
||||
// fetchPluginBanners returns normalized PluginBanner[] rather than the
|
||||
// raw contract response, so it uses its own cache key instead of
|
||||
// impersonating the generated banners.list contract query.
|
||||
queryKey: ['marketplace-banners', locale],
|
||||
queryFn: () => fetchPluginBanners(locale),
|
||||
enabled: variant === 'home',
|
||||
initialData: locale === initialLocale ? initialBanners : undefined,
|
||||
staleTime: BANNER_STALE_TIME,
|
||||
}),
|
||||
)
|
||||
|
||||
return <MarketplaceView {...props} banners={banners} variant={variant} />
|
||||
}
|
||||
40
web/app/components/plugins/marketplace/filter-track-link.tsx
Normal file
40
web/app/components/plugins/marketplace/filter-track-link.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import Link from '@/next/link'
|
||||
import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track'
|
||||
|
||||
type MarketplaceFilterTrackLinkProps = ComponentProps<typeof Link> & {
|
||||
filterValue: string
|
||||
filterType: 'type_tab' | 'category' | 'language'
|
||||
selectedValues: string[]
|
||||
selectionMode?: 'single' | 'multi'
|
||||
trackFilter?: boolean
|
||||
}
|
||||
|
||||
export default function MarketplaceFilterTrackLink({
|
||||
filterValue,
|
||||
filterType,
|
||||
selectedValues,
|
||||
selectionMode = 'single',
|
||||
trackFilter = true,
|
||||
onClick,
|
||||
...props
|
||||
}: MarketplaceFilterTrackLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
onClick={(event) => {
|
||||
if (trackFilter) {
|
||||
markMarketplaceSiteFilter({
|
||||
filter_type: filterType,
|
||||
selection_mode: selectionMode,
|
||||
filter_value: filterValue,
|
||||
selected_values: selectedValues,
|
||||
})
|
||||
}
|
||||
onClick?.(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
12
web/app/components/plugins/marketplace/home/README.md
Normal file
12
web/app/components/plugins/marketplace/home/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Marketplace Catalog Home
|
||||
|
||||
The redesigned Marketplace catalog shell provides the shared header, hero, search, trending, tabs, and sticky category navigation used by the Plugins and Templates pages.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- `marketplace/list/list-wrapper`
|
||||
- `marketplace/plugin-type-switch`
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
@ -0,0 +1,32 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import CatalogLanguagesFilter from '../catalog-languages-filter'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('CatalogLanguagesFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes selected languages into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(<CatalogLanguagesFilter />)
|
||||
await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: '中文' }))
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,47 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import CatalogTagsFilter from '../catalog-tags-filter'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
tags: [
|
||||
{ name: 'agent', label: 'Agent' },
|
||||
{ name: 'rag', label: 'RAG' },
|
||||
{ name: 'search', label: 'Search' },
|
||||
],
|
||||
tagsMap: {
|
||||
agent: { name: 'agent', label: 'Agent' },
|
||||
rag: { name: 'rag', label: 'RAG' },
|
||||
search: { name: 'search', label: 'Search' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CatalogTagsFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes selected tags into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderWithNuqs(<CatalogTagsFilter />)
|
||||
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,243 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import EmbeddedMarketplaceSearch from '../embedded-marketplace-search'
|
||||
|
||||
const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
|
||||
debounceState: { useRealDebounce: false },
|
||||
mockPluginSearch: vi.fn(),
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ahooks')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T, options?: { wait?: number }) =>
|
||||
debounceState.useRealDebounce ? original.useDebounce(value, options) : value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
const translations: Record<string, string> = {
|
||||
'marketplace.home.searchPlaceholder': 'Search plugins or templates',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
}
|
||||
|
||||
return {
|
||||
useLocale: () => 'en-US',
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => translations[key] ?? key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceQuery: {
|
||||
searchAdvanced: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'plugins', input],
|
||||
queryFn: () => mockPluginSearch(input),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'templates', input],
|
||||
queryFn: () => mockTemplateSearch(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
|
||||
default: () => ({ installedInfo: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../detail-dialog', () => ({
|
||||
default: ({ plugin }: { plugin: { name: string } }) => (
|
||||
<div role="dialog" aria-label="plugin-detail">
|
||||
{plugin.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../templates/template-detail-dialog', () => ({
|
||||
default: ({ template }: { template: { template_name: string } }) => (
|
||||
<div role="dialog" aria-label="template-detail">
|
||||
{template.template_name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
const renderSearch = () => {
|
||||
const { onUrlUpdate } = renderWithNuqs(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EmbeddedMarketplaceSearch />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return { onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('EmbeddedMarketplaceSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
debounceState.useRealDebounce = false
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
})
|
||||
|
||||
it('shows mixed plugin and template suggestions in the in-app search popup', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
|
||||
const templateGroup = await screen.findByRole('group', { name: 'Templates' })
|
||||
const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
|
||||
expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument()
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens plugin and template details from the popup without filtering the catalog', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
plugin_id: 'langgenius/google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('google-search')
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
await user.click(await screen.findByText('Legal Research Agent'))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'template-detail' })).toHaveTextContent(
|
||||
'Legal Research Agent',
|
||||
)
|
||||
expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the current catalog when Enter is pressed instead of opening a result', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
plugin_id: 'langgenius/google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSearch()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await user.hover(await screen.findByRole('option', { name: /Google Search/ }))
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EMBEDDED_MOBILE_BANNER_MEDIA,
|
||||
MARKETPLACE_MOBILE_BANNER_MEDIA,
|
||||
marketplaceTabletBannerMedia,
|
||||
resolveEventAdBannerImageSrcs,
|
||||
} from '../event-ad-banner-image'
|
||||
|
||||
describe('resolveEventAdBannerImageSrcs', () => {
|
||||
it('uses the mobile asset on the mobile slot when one exists', () => {
|
||||
expect(
|
||||
resolveEventAdBannerImageSrcs({
|
||||
desktop: '/desktop.png',
|
||||
tablet: '/tablet.png',
|
||||
mobile: '/mobile.png',
|
||||
}),
|
||||
).toEqual({
|
||||
desktop: '/desktop.png',
|
||||
mobile: '/mobile.png',
|
||||
tablet: '/tablet.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to desktop on the mobile slot when mobile is missing', () => {
|
||||
expect(
|
||||
resolveEventAdBannerImageSrcs({
|
||||
desktop: '/desktop.png',
|
||||
tablet: '/tablet.png',
|
||||
}),
|
||||
).toEqual({
|
||||
desktop: '/desktop.png',
|
||||
mobile: '/desktop.png',
|
||||
tablet: '/tablet.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits tablet when the banner has no tablet asset', () => {
|
||||
expect(
|
||||
resolveEventAdBannerImageSrcs({
|
||||
desktop: '/desktop.png',
|
||||
mobile: '/mobile.png',
|
||||
}),
|
||||
).toEqual({
|
||||
desktop: '/desktop.png',
|
||||
mobile: '/mobile.png',
|
||||
tablet: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('marketplaceTabletBannerMedia', () => {
|
||||
it('keeps tablet out of the standalone mobile breakpoint', () => {
|
||||
expect(MARKETPLACE_MOBILE_BANNER_MEDIA).toBe('(max-width: 879px)')
|
||||
expect(marketplaceTabletBannerMedia(true)).toBe('(min-width: 880px) and (max-width: 1023px)')
|
||||
})
|
||||
|
||||
it('keeps tablet out of the embedded mobile breakpoint', () => {
|
||||
expect(EMBEDDED_MOBILE_BANNER_MEDIA).toBe('(max-width: 639px)')
|
||||
expect(marketplaceTabletBannerMedia(false)).toBe('(min-width: 640px) and (max-width: 1023px)')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,41 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
describe('Marketplace home catalog alignment', () => {
|
||||
it('aligns catalog tabs and filters with the content container', async () => {
|
||||
const screen = await render(
|
||||
<HomeStickyStateProvider>
|
||||
<div className="w-[1200px]" data-marketplace-standalone>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={
|
||||
<div data-testid="catalog-filter" role="group" aria-label="Categories" />
|
||||
}
|
||||
catalogTabs={
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{ plugins: 'Plugins', templates: 'Templates' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={`px-8 ${styles.catalogContent}`}>
|
||||
<div role="region" aria-label="Catalog content" className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
const contentLeft = screen
|
||||
.getByRole('region', { name: 'Catalog content' })
|
||||
.element()
|
||||
.getBoundingClientRect().left
|
||||
const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left
|
||||
const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left
|
||||
|
||||
expect(tabsLeft).toBeCloseTo(contentLeft)
|
||||
expect(filtersLeft).toBeCloseTo(contentLeft)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,241 @@
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import {
|
||||
HOME_HEADER_HEIGHT_PX,
|
||||
HOME_SEARCH_HEIGHT_PX,
|
||||
HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX,
|
||||
} from '../home-constants'
|
||||
import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
describe('Marketplace catalog tab handoff', () => {
|
||||
it('hands off only when the in-flow tabs fully reach the sticky header', async () => {
|
||||
await page.viewport(1200, 800)
|
||||
const screen = await render(
|
||||
<HomeStickyStateProvider>
|
||||
<div
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
data-marketplace-standalone
|
||||
style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }}
|
||||
>
|
||||
<div
|
||||
data-testid="catalog-header"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
height: 48,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
<HomeStickyCatalogTabs>
|
||||
<div className={styles.headerCatalogTabs} data-testid="header-catalog-tabs">
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{ plugins: 'Plugins', templates: 'Templates' }}
|
||||
/>
|
||||
</div>
|
||||
</HomeStickyCatalogTabs>
|
||||
</div>
|
||||
<div style={{ height: 220, flexShrink: 0 }} />
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={<div data-testid="catalog-categories">Categories</div>}
|
||||
catalogTabs={
|
||||
<div data-testid="content-catalog-tabs">
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{ plugins: 'Plugins', templates: 'Templates' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div data-testid="following-content" style={{ height: 640, flexShrink: 0 }} />
|
||||
</div>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const header = screen.getByTestId('catalog-header').element()
|
||||
const navigation = screen.getByRole('region').element()
|
||||
const categories = screen.getByTestId('catalog-categories').element()
|
||||
const contentTabsSlot = screen.getByTestId('content-catalog-tabs').element().parentElement!
|
||||
const contentTabsRegion = contentTabsSlot.parentElement!
|
||||
const headerTabsSlot = screen.getByTestId('header-catalog-tabs').element().parentElement!
|
||||
const followingContent = screen.getByTestId('following-content').element() as HTMLElement
|
||||
const initialHeight = navigation.getBoundingClientRect().height
|
||||
const initialCategoryOffset =
|
||||
categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top
|
||||
const initialHeaderSlotWidth = headerTabsSlot.getBoundingClientRect().width
|
||||
const initialHeaderSlotHeight = headerTabsSlot.getBoundingClientRect().height
|
||||
const initialFollowingOffset = followingContent.offsetTop
|
||||
const initialScrollHeight = scrollContainer.scrollHeight
|
||||
const contentPluginsLink =
|
||||
contentTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')!
|
||||
const headerPluginsLink = headerTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')!
|
||||
|
||||
expect(initialHeaderSlotWidth).toBeGreaterThan(0)
|
||||
expect(initialHeaderSlotHeight).toBeGreaterThan(0)
|
||||
expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('none')
|
||||
expect(getComputedStyle(headerTabsSlot).transitionProperty).toBe('opacity, transform')
|
||||
expect(getComputedStyle(headerTabsSlot).transitionDuration).toBe('0.14s')
|
||||
|
||||
contentPluginsLink.focus()
|
||||
expect(document.activeElement).toBe(contentPluginsLink)
|
||||
|
||||
const handoffScrollTop =
|
||||
scrollContainer.scrollTop +
|
||||
contentTabsRegion.getBoundingClientRect().bottom -
|
||||
header.getBoundingClientRect().bottom
|
||||
scrollContainer.scrollTop = handoffScrollTop - 1
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
)
|
||||
|
||||
expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1)
|
||||
expect(
|
||||
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
|
||||
).toBeCloseTo(1)
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
expect(document.activeElement).toBe(contentPluginsLink)
|
||||
|
||||
scrollContainer.scrollTop = handoffScrollTop
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await vi.waitFor(() => {
|
||||
expect(navigation).toHaveClass(styles.catalogNavigationPinned!)
|
||||
})
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(handoffScrollTop)
|
||||
expect(navigation.getBoundingClientRect().height).toBeCloseTo(initialHeight)
|
||||
expect(
|
||||
categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top,
|
||||
).toBeCloseTo(initialCategoryOffset)
|
||||
expect(
|
||||
categories.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
).toBeCloseTo(64)
|
||||
expect(
|
||||
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
|
||||
).toBeCloseTo(0)
|
||||
expect(headerTabsSlot.getBoundingClientRect().width).toBeCloseTo(initialHeaderSlotWidth)
|
||||
expect(headerTabsSlot.getBoundingClientRect().height).toBeCloseTo(initialHeaderSlotHeight)
|
||||
expect(followingContent.offsetTop).toBe(initialFollowingOffset)
|
||||
expect(scrollContainer.scrollHeight).toBe(initialScrollHeight)
|
||||
expect(getComputedStyle(contentTabsSlot).display).not.toBe('none')
|
||||
expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('none')
|
||||
expect(getComputedStyle(contentTabsSlot).transitionProperty).toBe('opacity, transform')
|
||||
expect(getComputedStyle(contentTabsSlot).transitionDuration).toBe('0.14s')
|
||||
expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(contentTabsSlot).toHaveAttribute('inert')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('auto')
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(getComputedStyle(contentTabsSlot).opacity).toBe('0')
|
||||
expect(getComputedStyle(headerTabsSlot).opacity).toBe('1')
|
||||
expect(document.activeElement).toBe(headerPluginsLink)
|
||||
},
|
||||
{ timeout: 500 },
|
||||
)
|
||||
|
||||
scrollContainer.scrollTop = handoffScrollTop - 1
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(contentPluginsLink)
|
||||
})
|
||||
|
||||
expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1)
|
||||
expect(
|
||||
contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
|
||||
).toBeCloseTo(1)
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('keeps the in-flow tabs active when the standalone header slot is hidden on mobile', async () => {
|
||||
await page.viewport(879, 800)
|
||||
const screen = await render(
|
||||
<HomeStickyStateProvider>
|
||||
<div
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
data-marketplace-standalone
|
||||
style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }}
|
||||
>
|
||||
<div style={{ display: 'flex', height: 48, flexShrink: 0 }}>
|
||||
<HomeStickyCatalogTabs>
|
||||
<div className={styles.headerCatalogTabs} data-testid="mobile-header-tabs">
|
||||
Header tabs
|
||||
</div>
|
||||
</HomeStickyCatalogTabs>
|
||||
</div>
|
||||
<div style={{ height: 220, flexShrink: 0 }} />
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={<div>Categories</div>}
|
||||
catalogTabs={<div data-testid="mobile-content-tabs">Content tabs</div>}
|
||||
/>
|
||||
<div style={{ height: 640, flexShrink: 0 }} />
|
||||
</div>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const navigation = screen.getByRole('region').element()
|
||||
const contentTabsSlot = screen.getByTestId('mobile-content-tabs').element().parentElement!
|
||||
const headerTabs = screen.getByTestId('mobile-header-tabs').element()
|
||||
const headerTabsSlot = headerTabs.parentElement!
|
||||
|
||||
expect(getComputedStyle(headerTabs).display).toBe('none')
|
||||
|
||||
scrollContainer.scrollTop = 300
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(getComputedStyle(contentTabsSlot).opacity).toBe('1')
|
||||
expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('auto')
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
expect(
|
||||
contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
).toBeCloseTo(
|
||||
HOME_HEADER_HEIGHT_PX +
|
||||
HOME_SEARCH_HEIGHT_PX +
|
||||
HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX /* .catalogTabsRegion padding-top, tucked under search padding */,
|
||||
)
|
||||
|
||||
await page.viewport(880, 800)
|
||||
await vi.waitFor(() => {
|
||||
expect(navigation).toHaveClass(styles.catalogNavigationPinned!)
|
||||
})
|
||||
expect(getComputedStyle(headerTabs).display).toBe('flex')
|
||||
expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(contentTabsSlot).toHaveAttribute('inert')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('inert')
|
||||
|
||||
await page.viewport(879, 800)
|
||||
await vi.waitFor(() => {
|
||||
expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
|
||||
})
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,320 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../plugin-type-switch', () => ({
|
||||
default: ({ className, variant }: { className?: string; variant?: string }) => (
|
||||
<div data-testid="plugin-type-switch" className={className} data-variant={variant} />
|
||||
),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('#marketplace-container').forEach((element) => element.remove())
|
||||
})
|
||||
|
||||
describe('HomeCatalogNavigation', () => {
|
||||
const renderNavigation = (isMarketplacePlatform: boolean) => {
|
||||
return render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeStickyCatalogTabs>
|
||||
<div data-testid="header-catalog-tabs" />
|
||||
</HomeStickyCatalogTabs>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps template navigation inside the Marketplace platform', () => {
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigation!)
|
||||
expect(navigationSection.firstElementChild).toHaveClass('w-full')
|
||||
expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]')
|
||||
const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
expect(activeTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(activeTab).toHaveAttribute('href', '/plugins')
|
||||
expect(activeTab).toHaveClass('bg-state-base-active')
|
||||
expect(activeTab).not.toHaveClass('text-text-accent')
|
||||
expect(activeTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', '/templates')
|
||||
expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home')
|
||||
})
|
||||
|
||||
it('keeps tabs clickable and uses only the active background', () => {
|
||||
render(<HomeCatalogTabs isMarketplacePlatform />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).toHaveAttribute('href', '/plugins')
|
||||
expect(pluginsTab).toHaveClass('cursor-pointer')
|
||||
expect(pluginsTab).toHaveClass('bg-state-base-active')
|
||||
expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
expect(templatesTab).toHaveAttribute('href', '/templates')
|
||||
expect(templatesTab).toHaveClass('cursor-pointer')
|
||||
expect(templatesTab).not.toHaveClass('bg-state-base-active')
|
||||
})
|
||||
|
||||
it('leaves both catalog tabs inactive when no page is selected', () => {
|
||||
render(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).toHaveAttribute('href', '/marketplace')
|
||||
expect(pluginsTab).not.toHaveAttribute('aria-current')
|
||||
expect(pluginsTab).not.toHaveClass('bg-state-base-active')
|
||||
expect(templatesTab).not.toHaveAttribute('aria-current')
|
||||
expect(templatesTab).not.toHaveClass('bg-state-base-active')
|
||||
})
|
||||
|
||||
it('marks Templates as active when rendering the Templates catalog', () => {
|
||||
render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).not.toHaveAttribute('aria-current')
|
||||
expect(pluginsTab).not.toHaveClass('bg-state-base-active')
|
||||
expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
expect(templatesTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(templatesTab).toHaveClass('bg-state-base-active')
|
||||
expect(templatesTab).not.toHaveClass('text-text-accent')
|
||||
expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses request-localized labels and preserves the selected language', () => {
|
||||
render(
|
||||
<HomeCatalogTabs
|
||||
isMarketplacePlatform
|
||||
labels={{
|
||||
plugins: '插件',
|
||||
templates: '模板',
|
||||
}}
|
||||
language="zh-Hans"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plugins?language=zh-Hans',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute(
|
||||
'href',
|
||||
'/templates?language=zh-Hans',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a supplied catalog category navigation', () => {
|
||||
render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />}
|
||||
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses a short divider between the leading tag filter and categories', () => {
|
||||
render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogTabs={<HomeCatalogTabs isMarketplacePlatform />}
|
||||
catalogLeading={<div>Tags</div>}
|
||||
catalogTrailing={<div>Languages</div>}
|
||||
catalogCategories={<nav aria-label="Plugin categories">Categories</nav>}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
// Categories sit in the flex-1 scroller; the row is one level up.
|
||||
const row = screen.getByRole('navigation', { name: 'Plugin categories' }).parentElement
|
||||
?.parentElement
|
||||
const divider = row?.children.item(1)
|
||||
|
||||
expect(row?.children.item(0)).toHaveTextContent('Tags')
|
||||
expect(divider).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(divider).toHaveClass(
|
||||
'mx-1',
|
||||
'h-3.5',
|
||||
'w-px',
|
||||
'shrink-0',
|
||||
'bg-divider-regular',
|
||||
styles.catalogLeadingDivider!,
|
||||
)
|
||||
expect(divider).toBeEmptyDOMElement()
|
||||
expect(row?.children.item(2)).toHaveTextContent('Categories')
|
||||
expect(row?.children.item(3)).toHaveTextContent('Languages')
|
||||
expect(row).not.toHaveTextContent('·')
|
||||
})
|
||||
|
||||
it('keeps Dify catalog navigation on the current origin', () => {
|
||||
renderNavigation(false)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
|
||||
'href',
|
||||
'/marketplace',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', '/templates')
|
||||
})
|
||||
|
||||
it('keeps both tab copies mounted while exposing only the active copy', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
const containerRect = vi
|
||||
.spyOn(scrollContainer, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(0, -100, 100, 100))
|
||||
|
||||
renderNavigation(true)
|
||||
|
||||
const contentTabs = document.querySelector<HTMLElement>(
|
||||
'[data-home-catalog-tabs-slot="content"]',
|
||||
)!
|
||||
const catalogTabsRegion = contentTabs.parentElement!
|
||||
const headerTabs = screen.getByTestId('header-catalog-tabs')
|
||||
const headerTabsSlot = headerTabs.parentElement!
|
||||
const handoffBoundaryRect = vi
|
||||
.spyOn(catalogTabsRegion, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(0, -7, 100, 56))
|
||||
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
expect(contentTabs).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabs).not.toHaveAttribute('inert')
|
||||
|
||||
containerRect.mockReturnValue(new DOMRect(0, 0, 100, 100))
|
||||
handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56))
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(headerTabs).toBeInTheDocument()
|
||||
expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(contentTabs).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(contentTabs).toHaveAttribute('inert')
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
|
||||
it('shows the compact navigation and header tabs after reaching the sticky header', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
const contentTabsSlot = document.querySelector<HTMLElement>(
|
||||
'[data-home-catalog-tabs-slot="content"]',
|
||||
)!
|
||||
const catalogTabsRegion = contentTabsSlot.parentElement!
|
||||
const headerTabs = screen.getByTestId('header-catalog-tabs')
|
||||
const headerTabsSlot = headerTabs.parentElement!
|
||||
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
|
||||
const handoffBoundaryRect = vi
|
||||
.spyOn(catalogTabsRegion, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(0, -7, 100, 56))
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
expect(headerTabs).toBeInTheDocument()
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
|
||||
handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56))
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(contentTabsSlot).toHaveClass(styles.catalogTabsPinned!)
|
||||
expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(headerTabsSlot).not.toHaveAttribute('inert')
|
||||
expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(contentTabsSlot).toHaveAttribute('inert')
|
||||
|
||||
handoffBoundaryRect.mockReturnValue(new DOMRect(0, -7, 100, 56))
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).not.toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(headerTabs).toBeInTheDocument()
|
||||
expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(headerTabsSlot).toHaveAttribute('inert')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
|
||||
expect(contentTabsSlot).not.toHaveAttribute('inert')
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
|
||||
it('keeps the pinned state when compact styling moves the sticky section', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
const contentTabsSlot = document.querySelector<HTMLElement>(
|
||||
'[data-home-catalog-tabs-slot="content"]',
|
||||
)!
|
||||
const catalogTabsRegion = contentTabsSlot.parentElement!
|
||||
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
|
||||
vi.spyOn(catalogTabsRegion, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, -9, 100, 56),
|
||||
)
|
||||
vi.spyOn(navigationSection, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 49, 100, 60),
|
||||
)
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!)
|
||||
expect(screen.getByTestId('header-catalog-tabs').parentElement).not.toHaveAttribute(
|
||||
'aria-hidden',
|
||||
)
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
|
||||
it('leaves browser scroll anchoring enabled because the handoff preserves geometry', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
const { unmount } = renderNavigation(true)
|
||||
|
||||
expect(scrollContainer.style.overflowAnchor).toBe('')
|
||||
|
||||
unmount()
|
||||
expect(scrollContainer.style.overflowAnchor).toBe('')
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,129 @@
|
||||
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import HomeGuide from '../home-guide'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
|
||||
useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'en-US',
|
||||
},
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
useDocLink: mocks.useDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get MARKETPLACE_URL_PREFIX() {
|
||||
return mocks.marketplaceUrlPrefix
|
||||
},
|
||||
}))
|
||||
|
||||
const GUIDE_BUTTON_NAME = /marketplace\.home\.guide/
|
||||
|
||||
const renderGuide = (isMarketplacePlatform: boolean) =>
|
||||
render(
|
||||
<TooltipProvider delay={0} closeDelay={0}>
|
||||
<HomeGuide isMarketplacePlatform={isMarketplacePlatform} />
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
const openGuideMenu = async (isMarketplacePlatform: boolean) => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(isMarketplacePlatform)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: GUIDE_BUTTON_NAME }))
|
||||
return within(await screen.findByRole('menu'))
|
||||
}
|
||||
|
||||
describe('HomeGuide', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
|
||||
})
|
||||
|
||||
it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => {
|
||||
const menu = await openGuideMenu(true)
|
||||
const options = menu.getAllByRole('menuitem')
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[0]).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
|
||||
)
|
||||
expect(options[1]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
expect(options[2]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview',
|
||||
)
|
||||
expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai')
|
||||
expect(mocks.useDocLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Dify deployment-aware documentation links inside the console', async () => {
|
||||
const menu = await openGuideMenu(false)
|
||||
const options = menu.getAllByRole('menuitem')
|
||||
|
||||
expect(options).toHaveLength(4)
|
||||
expect(options[1]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
|
||||
)
|
||||
expect(options[2]).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview',
|
||||
)
|
||||
expect(mocks.useDocLink).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('labels the in-app Guide icon and shows a matching tooltip on hover and focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(false)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
|
||||
expect(trigger).toHaveAccessibleName(/marketplace\.home\.guide/)
|
||||
|
||||
await user.hover(trigger)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/)
|
||||
|
||||
await user.unhover(trigger)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/)
|
||||
})
|
||||
|
||||
it('keeps the Guide dropdown available after the tooltip is shown', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGuide(false)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME })
|
||||
await user.hover(trigger)
|
||||
expect(await screen.findByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,155 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import HomeHeader from '../home-header'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
marketplaceUrlPrefix: 'https://marketplace.dify.ai',
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'en-US',
|
||||
},
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
defaultDocBaseUrl: 'https://docs.dify.ai',
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/console${path || ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get MARKETPLACE_URL_PREFIX() {
|
||||
return mocks.marketplaceUrlPrefix
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../home-sticky-state-provider', () => ({
|
||||
HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
describe('HomeHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
|
||||
})
|
||||
|
||||
it('keeps Creator Center and docs in the in-app header without an account action', () => {
|
||||
render(<HomeHeader isMarketplacePlatform={false} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('account-section')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Creator Center before the docs dropdown', () => {
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
|
||||
const guideButton = screen.getByRole('button', { name: /marketplace\.home\.guide/ })
|
||||
|
||||
expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
expect(creatorCenterLink).toHaveAttribute('target', '_blank')
|
||||
expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(creatorCenterLink.parentElement?.className).toMatch(/standaloneHeaderActions/)
|
||||
expect(creatorCenterLink.compareDocumentPosition(guideButton)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
// Creator Center must be a single interactive element, not a link-wrapped button.
|
||||
expect(creatorCenterLink.querySelector('button')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links Creator Center to the staging Creators site in staging', () => {
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev'
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators-staging.dify.dev/',
|
||||
)
|
||||
})
|
||||
|
||||
it('links Creator Center to the dev Creators site on marketplace.dify.dev', () => {
|
||||
mocks.marketplaceUrlPrefix = 'https://marketplace.dify.dev'
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators.dify.dev/',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the public Creator Center for a custom Marketplace origin', () => {
|
||||
mocks.marketplaceUrlPrefix = 'http://localhost:3000'
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators.dify.ai/',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the Marketplace wordmark without a Marketplace text label', () => {
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
const brandLink = screen.getByRole('link', { name: 'Dify Marketplace' })
|
||||
const [lightLogo, darkLogo] = brandLink.querySelectorAll('img')
|
||||
expect(lightLogo).toHaveAttribute('src', expect.stringContaining('dify-marketplace-logo.svg'))
|
||||
expect(darkLogo).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('dify-marketplace-logo-dark.svg'),
|
||||
)
|
||||
expect(lightLogo).toHaveAttribute('width', '141.761')
|
||||
expect(lightLogo).toHaveAttribute('height', '16.386')
|
||||
expect(darkLogo).toHaveAttribute('width', '141.761')
|
||||
expect(darkLogo).toHaveAttribute('height', '16.386')
|
||||
expect(screen.queryByText('mainNav.marketplace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects neither catalog tab on non-catalog pages', () => {
|
||||
render(
|
||||
<HomeHeader
|
||||
activeTab={null}
|
||||
catalogLabels={{
|
||||
plugins: 'Plugins',
|
||||
templates: 'Templates',
|
||||
}}
|
||||
isMarketplacePlatform
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current')
|
||||
expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('shows Templates with only the active background on the Templates catalog', () => {
|
||||
render(
|
||||
<HomeHeader
|
||||
activeTab="templates"
|
||||
catalogLabels={{
|
||||
plugins: '插件',
|
||||
templates: '模板',
|
||||
}}
|
||||
isMarketplacePlatform
|
||||
language="zh-Hans"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current')
|
||||
const templatesTab = screen.getByRole('link', { name: '模板' })
|
||||
expect(templatesTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans')
|
||||
expect(templatesTab).toHaveClass('bg-state-base-active')
|
||||
expect(templatesTab).not.toHaveClass('text-text-accent')
|
||||
expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from '../home-constants'
|
||||
import HomeHero from '../home-hero'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('HomeHero', () => {
|
||||
it('renders catalog-specific copy when supplied', () => {
|
||||
render(
|
||||
<HomeHero
|
||||
isMarketplacePlatform
|
||||
title="Discover templates"
|
||||
subtitle="Start faster with ready-to-use workflows."
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the six decorative hero icons as images instead of iconify masks', () => {
|
||||
const { container } = render(<HomeHero isMarketplacePlatform />)
|
||||
|
||||
for (const name of [
|
||||
'sparkling-fill',
|
||||
'plug-fill',
|
||||
'puzzle-fill',
|
||||
'brain-2-fill',
|
||||
'image-circle-ai-line',
|
||||
'voice-ai-fill',
|
||||
])
|
||||
expect(container.querySelector(`img[src*="${name}"]`)).not.toBeNull()
|
||||
|
||||
expect(container.querySelector('img[src*="google"]')).toBeNull()
|
||||
expect(container.querySelector('.i-ri-sparkling-fill')).toBeNull()
|
||||
expect(container.querySelector('.i-custom-public-common-gmail')).toBeNull()
|
||||
})
|
||||
|
||||
it('places each decorative icon flush inside a 41px grid cell', () => {
|
||||
expect(HERO_ICON_SIZE_PX).toBe(HERO_GRID_PITCH_PX - 1)
|
||||
|
||||
const { container } = render(<HomeHero isMarketplacePlatform />)
|
||||
const icons = [...container.querySelectorAll<HTMLElement>('[aria-hidden] span.absolute')]
|
||||
expect(icons).toHaveLength(6)
|
||||
|
||||
const plusOffset = /^calc\(50% \+ (-?\d+)px\)$/
|
||||
const minusOffset = /^calc\(50% - (\d+)px\)$/
|
||||
|
||||
for (const icon of icons) {
|
||||
const plusMatch = plusOffset.exec(icon.style.left)
|
||||
const minusMatch = minusOffset.exec(icon.style.left)
|
||||
const left = plusMatch
|
||||
? Number(plusMatch[1])
|
||||
: minusMatch
|
||||
? -Number(minusMatch[1])
|
||||
: Number.NaN
|
||||
const top = Number.parseFloat(icon.style.top)
|
||||
|
||||
expect(left).not.toBeNaN()
|
||||
expect((left - 1) % HERO_GRID_PITCH_PX === 0).toBe(true)
|
||||
expect(top % HERO_GRID_PITCH_PX === 0).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts vertical grid lines on the same 50% origin as the icons', () => {
|
||||
const css = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../home-hero.module.css'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(css).toMatch(/background-position:\s*calc\(50% \+ 0\.5px\)/)
|
||||
expect(css).toMatch(/\.frame\s*\{\s*height:\s*163px/)
|
||||
expect(css).toMatch(/\.glow\s*\{[\s\S]*?width:\s*555px/)
|
||||
expect(css).toMatch(/\.glow\s*\{[\s\S]*?height:\s*245px/)
|
||||
expect(css).toMatch(/\.glow\s*\{[\s\S]*?filter:\s*blur\(30px\)/)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,211 @@
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
|
||||
import { HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX } from '../home-constants'
|
||||
import HomeHeader from '../home-header'
|
||||
import HomeSearch from '../home-search'
|
||||
import { HomeShell } from '../home-shell'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
vi.mock('@/public/marketplace/dify-marketplace-logo-dark.svg', () => ({
|
||||
default: { src: '/marketplace/dify-marketplace-logo-dark.svg' },
|
||||
}))
|
||||
|
||||
vi.mock('@/public/marketplace/dify-marketplace-logo.svg', () => ({
|
||||
default: { src: '/marketplace/dify-marketplace-logo.svg' },
|
||||
}))
|
||||
|
||||
vi.mock('../home-catalog-tabs', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../home-creator-center', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../home-guide', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const nextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
const overlaps = (a: DOMRect, b: DOMRect) =>
|
||||
a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
||||
|
||||
const isCenterClickable = (target: Element) => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const node = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
return Boolean(node && target.contains(node))
|
||||
}
|
||||
|
||||
const renderMarketplaceHome = () =>
|
||||
render(
|
||||
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
|
||||
<HomeShell
|
||||
banners={[]}
|
||||
header={
|
||||
<HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform />
|
||||
}
|
||||
hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />}
|
||||
isMarketplacePlatform
|
||||
navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />}
|
||||
page="plugins"
|
||||
search={
|
||||
<HomeSearch enableSearchShortcut={false}>
|
||||
<input
|
||||
aria-label="Search plugins or templates"
|
||||
style={{ display: 'block', height: 36, width: '100%' }}
|
||||
/>
|
||||
</HomeSearch>
|
||||
}
|
||||
>
|
||||
<div aria-hidden style={{ height: 640, flexShrink: 0 }} />
|
||||
</HomeShell>
|
||||
</div>,
|
||||
)
|
||||
|
||||
describe('Marketplace mobile search layout', () => {
|
||||
it('pins the mobile search below the header without covering brand or actions', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const screen = await renderMarketplaceHome()
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const header = screen.getByRole('banner').element()
|
||||
const brand = screen.getByRole('link', { name: 'Dify Marketplace' }).element()
|
||||
const signIn = screen.getByRole('button', { name: 'Sign in' }).element()
|
||||
const searchInput = screen
|
||||
.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
.element()
|
||||
|
||||
scrollContainer.scrollTop = 400
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
const headerRect = header.getBoundingClientRect()
|
||||
const searchRect = searchInput.getBoundingClientRect()
|
||||
|
||||
expect(searchRect.top).toBeGreaterThanOrEqual(headerRect.bottom - 1)
|
||||
expect(searchRect.top).toBeLessThanOrEqual(headerRect.bottom + 2)
|
||||
expect(overlaps(searchRect, brand.getBoundingClientRect())).toBe(false)
|
||||
expect(overlaps(searchRect, signIn.getBoundingClientRect())).toBe(false)
|
||||
expect(isCenterClickable(brand)).toBe(true)
|
||||
expect(isCenterClickable(signIn)).toBe(true)
|
||||
expect(isCenterClickable(searchInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bottom padding under the stuck mobile search', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const screen = await renderMarketplaceHome()
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const searchInput = screen
|
||||
.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
.element()
|
||||
|
||||
scrollContainer.scrollTop = 400
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
const searchRow = document.querySelector(`.${styles.search}`)!
|
||||
const inputRect = searchInput.getBoundingClientRect()
|
||||
const rowRect = searchRow.getBoundingClientRect()
|
||||
|
||||
expect(getComputedStyle(searchRow).paddingBottom).toBe(
|
||||
`${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`,
|
||||
)
|
||||
expect(rowRect.bottom - inputRect.bottom).toBeCloseTo(HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, 0)
|
||||
})
|
||||
|
||||
it('keeps the desktop search in the header gap while scrolling', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
const screen = await renderMarketplaceHome()
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const header = screen.getByRole('banner').element()
|
||||
const searchInput = screen
|
||||
.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
.element()
|
||||
|
||||
scrollContainer.scrollTop = 400
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
expect(
|
||||
searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top,
|
||||
).toBeCloseTo(6, 0)
|
||||
expect(getComputedStyle(document.querySelector(`.${styles.search}`)!).paddingBottom).toBe('0px')
|
||||
})
|
||||
|
||||
it('keeps a search-results search below the header when there is no hero to overlap', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
const screen = await render(
|
||||
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
|
||||
<HomeShell
|
||||
banners={[]}
|
||||
header={
|
||||
<HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform />
|
||||
}
|
||||
hero={null}
|
||||
isMarketplacePlatform
|
||||
navigation={null}
|
||||
page="plugins"
|
||||
search={
|
||||
<HomeSearch enableSearchShortcut={false} overlapHero={false}>
|
||||
<input
|
||||
aria-label="Search plugins or templates"
|
||||
style={{ display: 'block', height: 36, width: '100%' }}
|
||||
/>
|
||||
</HomeSearch>
|
||||
}
|
||||
>
|
||||
<div aria-hidden style={{ height: 640, flexShrink: 0 }} />
|
||||
</HomeShell>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const header = screen.getByRole('banner').element()
|
||||
const searchInput = screen
|
||||
.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
.element()
|
||||
|
||||
expect(searchInput.getBoundingClientRect().top).toBeGreaterThanOrEqual(
|
||||
header.getBoundingClientRect().bottom - 1,
|
||||
)
|
||||
expect(searchInput.getBoundingClientRect().width).toBeGreaterThan(300)
|
||||
})
|
||||
|
||||
it('does not jump the page when the stuck desktop search is focused or typed into', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
const screen = await renderMarketplaceHome()
|
||||
|
||||
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const header = screen.getByRole('banner').element()
|
||||
const searchInput = screen
|
||||
.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
.element()
|
||||
|
||||
scrollContainer.scrollTop = 400
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
const scrollTopBefore = scrollContainer.scrollTop
|
||||
const inputTopBefore = searchInput.getBoundingClientRect().top
|
||||
expect(inputTopBefore - header.getBoundingClientRect().top).toBeCloseTo(6, 0)
|
||||
|
||||
const searchLocator = screen.getByRole('textbox', { name: 'Search plugins or templates' })
|
||||
await searchLocator.click()
|
||||
await nextFrame()
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
|
||||
expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
|
||||
|
||||
await searchLocator.fill('g')
|
||||
await nextFrame()
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
|
||||
expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,268 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import HomeTrending from '../home-trending'
|
||||
import { HomeBannerSlide } from '../home-trending-slides'
|
||||
|
||||
const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({
|
||||
id,
|
||||
style_type: 'blog',
|
||||
title,
|
||||
sort,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: title,
|
||||
subtitle: 'New Agent node support',
|
||||
description: 'Build agent workflows with the new Agent node.',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
})
|
||||
|
||||
const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0)
|
||||
const adBanner: PluginBanner = {
|
||||
id: 'ad',
|
||||
style_type: 'ad',
|
||||
title: 'Partner campaign',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/ad.png',
|
||||
},
|
||||
link: 'https://partner.example.com',
|
||||
alt_text: 'Partner campaign',
|
||||
},
|
||||
}
|
||||
const eventBanner: PluginBanner = {
|
||||
id: 'event',
|
||||
style_type: 'event',
|
||||
title: 'Launch event',
|
||||
sort: 2,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/event.png',
|
||||
},
|
||||
link: 'https://dify.ai/event',
|
||||
alt_text: 'Launch event',
|
||||
},
|
||||
}
|
||||
const carouselBanners = [
|
||||
createBlogBanner('first', 'First banner', 0),
|
||||
createBlogBanner('second', 'Second banner', 1),
|
||||
createBlogBanner('third', 'Third banner', 2),
|
||||
]
|
||||
|
||||
const visibleReadMore = (slide: Element) =>
|
||||
[...slide.querySelectorAll('[aria-hidden]')].find((el) => {
|
||||
const text = el.textContent ?? ''
|
||||
return /Read more|trendingReadMore/.test(text) && el.getBoundingClientRect().height > 0
|
||||
}) ?? null
|
||||
|
||||
describe('Marketplace home trending layout', () => {
|
||||
it('keeps standalone mobile blog banners at the stacked 357px height', async () => {
|
||||
await page.viewport(600, 900)
|
||||
await render(
|
||||
<div data-marketplace-standalone className="w-[560px]">
|
||||
<div data-testid="blog-banner">
|
||||
<HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const blogSlide = document.querySelector<HTMLElement>('[data-testid="blog-banner"] > a')!
|
||||
|
||||
expect(blogSlide.getBoundingClientRect().height).toBe(357)
|
||||
})
|
||||
|
||||
it('clamps standalone mobile blog subtitle to one line and description to two', async () => {
|
||||
await page.viewport(600, 900)
|
||||
const subtitleText =
|
||||
'On September 10, 2026, LangGenius K.K. will host its flagship annual conference in Tokyo.'
|
||||
const descriptionText =
|
||||
'It is a full day dedicated to turning generative AI from isolated pilots into real operations. Registration is open now for the second year of the conference.'
|
||||
const longTag = 'IF Con Tokyo 2026 Annual Conference Extra Long Label'
|
||||
const longTitle = 'IF Con Tokyo 2026: Turn “What If” into Production'
|
||||
const longBlog: PluginBanner = {
|
||||
id: 'blog-long',
|
||||
style_type: 'blog',
|
||||
title: longTag,
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: longTitle,
|
||||
subtitle: subtitleText,
|
||||
description: descriptionText,
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
}
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone className="w-[360px]">
|
||||
<HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const slide = screen.getByRole('link').element()
|
||||
const tag = screen.getByText(longTag).element()
|
||||
const title = screen.getByRole('heading', { name: longTitle }).element()
|
||||
const subtitle = screen.getByText(subtitleText).element()
|
||||
const description = screen.getByText(descriptionText).element()
|
||||
const slideBox = slide.getBoundingClientRect()
|
||||
const titleBox = title.getBoundingClientRect()
|
||||
|
||||
expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
|
||||
expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
|
||||
expect(getComputedStyle(title).whiteSpace).toBe('normal')
|
||||
expect(titleBox.height).toBeGreaterThan(24)
|
||||
expect(titleBox.left - slideBox.left).toBeCloseTo(20, 0)
|
||||
expect(slideBox.right - titleBox.right).toBeCloseTo(20, 0)
|
||||
expect(slideBox.height).toBeGreaterThan(357)
|
||||
expect(getComputedStyle(subtitle).whiteSpace).toBe('nowrap')
|
||||
expect(getComputedStyle(subtitle).textOverflow).toBe('ellipsis')
|
||||
expect(getComputedStyle(description).webkitLineClamp).toBe('2')
|
||||
expect(description.getBoundingClientRect().height).toBeCloseTo(40, 0)
|
||||
expect(visibleReadMore(slide)).toBeNull()
|
||||
})
|
||||
|
||||
it('clamps the desktop blog tag to one line and lets the title wrap', async () => {
|
||||
await page.viewport(1200, 900)
|
||||
const longTag =
|
||||
"Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents — extra-long green label"
|
||||
const longTitle =
|
||||
"Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and AgentsDify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents"
|
||||
const longBlog: PluginBanner = {
|
||||
...createBlogBanner('blog-desktop-long', longTitle, 0),
|
||||
title: longTag,
|
||||
}
|
||||
const screen = await render(
|
||||
<div className="w-[1100px]">
|
||||
<HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const tag = screen.getByText(longTag).element()
|
||||
const title = screen.getByRole('heading', { name: longTitle }).element()
|
||||
const tagBox = tag.getBoundingClientRect()
|
||||
const titleBox = title.getBoundingClientRect()
|
||||
|
||||
expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
|
||||
expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
|
||||
expect(tagBox.height).toBeLessThanOrEqual(20)
|
||||
expect(getComputedStyle(title).whiteSpace).toBe('normal')
|
||||
expect(titleBox.height).toBeGreaterThan(24)
|
||||
expect(visibleReadMore(screen.getByRole('link').element())).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the standalone mobile event poster at the 800:721 delivery ratio', async () => {
|
||||
await page.viewport(600, 900)
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone className="w-[360px]">
|
||||
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const slide = screen.getByRole('link', { name: 'Launch event' }).element()
|
||||
const box = slide.getBoundingClientRect()
|
||||
const artwork = slide.querySelector('img')
|
||||
|
||||
expect(box.height).toBeCloseTo((box.width * 721) / 800, 1)
|
||||
expect(artwork).not.toBeNull()
|
||||
expect(getComputedStyle(artwork!).objectFit).toBe('contain')
|
||||
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
|
||||
})
|
||||
|
||||
it('keeps event and ad artwork left-aligned so desktop cropping stays on the right', async () => {
|
||||
await page.viewport(1000, 900)
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone className="w-[960px]">
|
||||
<HomeBannerSlide banner={adBanner} isMarketplacePlatform page="plugins" />
|
||||
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
for (const name of ['Partner campaign', 'Launch event']) {
|
||||
const artwork = screen.getByRole('link', { name }).element().querySelector('img')
|
||||
|
||||
expect(artwork).not.toBeNull()
|
||||
expect(getComputedStyle(artwork!).objectFit).toBe('cover')
|
||||
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps blog artwork at 400px on desktop so shrinking clips the right', async () => {
|
||||
await page.viewport(1200, 900)
|
||||
const screen = await render(
|
||||
<div className="w-[900px]">
|
||||
<HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const artwork = screen.getByRole('link').element().querySelector('img')
|
||||
|
||||
expect(artwork).not.toBeNull()
|
||||
expect(artwork!.getBoundingClientRect().width).toBe(400)
|
||||
expect(getComputedStyle(artwork!).objectFit).toBe('cover')
|
||||
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
|
||||
})
|
||||
|
||||
it('keeps desktop event artwork at least 1200px wide so overflow clips the right', async () => {
|
||||
await page.viewport(1000, 900)
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone className="w-[900px]">
|
||||
<HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const artwork = screen
|
||||
.getByRole('link', { name: 'Launch event' })
|
||||
.element()
|
||||
.querySelector('img')
|
||||
|
||||
expect(artwork).not.toBeNull()
|
||||
expect(artwork!.getBoundingClientRect().width).toBeGreaterThanOrEqual(1200)
|
||||
expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
|
||||
})
|
||||
|
||||
it('keeps the blog artwork left corners rounded when its image is cropped', async () => {
|
||||
const screen = await render(
|
||||
<div className="w-[600px]">
|
||||
<HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const artwork = screen.getByRole('link').element().querySelector('img')
|
||||
|
||||
expect(artwork).not.toBeNull()
|
||||
expect(getComputedStyle(artwork!).borderTopLeftRadius).toBe('16px')
|
||||
expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px')
|
||||
})
|
||||
|
||||
it('wraps from the last banner back to the first visible slide', async () => {
|
||||
const screen = await render(
|
||||
<HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />,
|
||||
)
|
||||
|
||||
await screen.getByRole('button', { name: 'Third banner' }).click()
|
||||
expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
screen
|
||||
.getByRole('button', { name: 'First banner' })
|
||||
.element()
|
||||
.getAttribute('aria-current'),
|
||||
{ timeout: 8000 },
|
||||
)
|
||||
.toBe('true')
|
||||
|
||||
expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute(
|
||||
'inert',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import HomeTrending from '../home-trending'
|
||||
|
||||
const createBanner = (id: string, title: string, sort: number): PluginBanner => ({
|
||||
id,
|
||||
style_type: 'blog',
|
||||
title,
|
||||
sort,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: title,
|
||||
subtitle: `${title} subtitle`,
|
||||
description: `${title} description`,
|
||||
link: `https://example.com/${id}`,
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
})
|
||||
|
||||
const banners = [
|
||||
createBanner('first', 'First banner', 0),
|
||||
createBanner('second', 'Second banner', 1),
|
||||
createBanner('third', 'Third banner', 2),
|
||||
]
|
||||
|
||||
const dispatchTouchPointer = (
|
||||
target: Element,
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||
init: Pick<PointerEventInit, 'clientX' | 'clientY' | 'pointerId'>,
|
||||
) =>
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
isPrimary: true,
|
||||
pointerType: 'touch',
|
||||
...init,
|
||||
}),
|
||||
)
|
||||
|
||||
describe('Marketplace home trending mobile swipe', () => {
|
||||
it('switches in both directions without activating a dragged link or clearing Pause', async () => {
|
||||
await page.viewport(600, 900)
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone>
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const firstSlideLocator = screen.getByRole('group', { name: 'First banner' })
|
||||
const secondSlideLocator = screen.getByRole('group', {
|
||||
name: 'Second banner',
|
||||
includeHidden: true,
|
||||
})
|
||||
const firstSlide = firstSlideLocator.element()
|
||||
const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')!
|
||||
|
||||
dispatchTouchPointer(firstSlide, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 480,
|
||||
clientY: 160,
|
||||
})
|
||||
dispatchTouchPointer(firstSlide, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 300,
|
||||
clientY: 166,
|
||||
})
|
||||
await expect.element(secondSlideLocator).toBeVisible()
|
||||
dispatchTouchPointer(firstSlide, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 300,
|
||||
clientY: 166,
|
||||
})
|
||||
const clickWasNotCanceled = firstLink.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
)
|
||||
|
||||
expect(clickWasNotCanceled).toBe(false)
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Second banner' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).click()
|
||||
|
||||
const secondSlide = secondSlideLocator.element()
|
||||
dispatchTouchPointer(secondSlide, 'pointerdown', {
|
||||
pointerId: 2,
|
||||
clientX: 260,
|
||||
clientY: 160,
|
||||
})
|
||||
dispatchTouchPointer(secondSlide, 'pointermove', {
|
||||
pointerId: 2,
|
||||
clientX: 440,
|
||||
clientY: 166,
|
||||
})
|
||||
dispatchTouchPointer(secondSlide, 'pointerup', {
|
||||
pointerId: 2,
|
||||
clientX: 440,
|
||||
clientY: 166,
|
||||
})
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'First banner' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('suppresses the trailing click when a horizontal drag is pulled back before release', async () => {
|
||||
await page.viewport(600, 900)
|
||||
const screen = await render(
|
||||
<div data-marketplace-standalone>
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
const firstSlide = screen.getByRole('group', { name: 'First banner' }).element()
|
||||
const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')!
|
||||
|
||||
dispatchTouchPointer(firstSlide, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 400,
|
||||
clientY: 160,
|
||||
})
|
||||
dispatchTouchPointer(firstSlide, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 280,
|
||||
clientY: 164,
|
||||
})
|
||||
dispatchTouchPointer(firstSlide, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 396,
|
||||
clientY: 162,
|
||||
})
|
||||
dispatchTouchPointer(firstSlide, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 396,
|
||||
clientY: 162,
|
||||
})
|
||||
|
||||
expect(
|
||||
firstLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })),
|
||||
).toBe(false)
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'First banner' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('keeps vertical gestures on the current slide and ignores desktop touch input', async () => {
|
||||
await page.viewport(600, 900)
|
||||
let screen = await render(
|
||||
<div data-marketplace-standalone>
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
let activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
|
||||
|
||||
dispatchTouchPointer(activeSlide, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 300,
|
||||
clientY: 120,
|
||||
})
|
||||
dispatchTouchPointer(activeSlide, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 270,
|
||||
clientY: 300,
|
||||
})
|
||||
dispatchTouchPointer(activeSlide, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 270,
|
||||
clientY: 300,
|
||||
})
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'First banner' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
|
||||
screen.unmount()
|
||||
await page.viewport(1000, 900)
|
||||
screen = await render(
|
||||
<div data-marketplace-standalone>
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />
|
||||
</div>,
|
||||
)
|
||||
activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
|
||||
|
||||
dispatchTouchPointer(activeSlide, 'pointerdown', {
|
||||
pointerId: 2,
|
||||
clientX: 480,
|
||||
clientY: 160,
|
||||
})
|
||||
dispatchTouchPointer(activeSlide, 'pointermove', {
|
||||
pointerId: 2,
|
||||
clientX: 260,
|
||||
clientY: 160,
|
||||
})
|
||||
dispatchTouchPointer(activeSlide, 'pointerup', {
|
||||
pointerId: 2,
|
||||
clientX: 260,
|
||||
clientY: 160,
|
||||
})
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'First banner' }))
|
||||
.toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,911 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
|
||||
import HomeTrending from '../home-trending'
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/marketplace-site-track', () => ({
|
||||
rememberMarketplaceSiteReferrer: vi.fn(),
|
||||
trackMarketplaceSiteEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: (namespace: string) => ({
|
||||
t: withSelectorKey((key: string) => `${namespace}.${key}`),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
|
||||
default: () => <span data-testid="partner-badge" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
|
||||
}))
|
||||
|
||||
const banners: PluginBanner[] = [
|
||||
{
|
||||
id: 'recommend',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
heading: 'Popular plugins',
|
||||
description: 'Chosen from real usage.',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
display_name: 'Dropbox',
|
||||
icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
|
||||
creator: 'langgenius',
|
||||
badges: ['partner', 'verified'],
|
||||
link: '/plugins/langgenius/dropbox',
|
||||
card_position: 0,
|
||||
auto_batch_id: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/zapier',
|
||||
display_name: 'Zapier',
|
||||
link: '/plugins/langgenius/zapier',
|
||||
card_position: 1,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/notion',
|
||||
display_name: 'Notion',
|
||||
link: '/plugins/langgenius/notion',
|
||||
card_position: 2,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/slack',
|
||||
display_name: 'Slack',
|
||||
link: '/plugins/langgenius/slack',
|
||||
card_position: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
style_type: 'blog',
|
||||
title: 'Dify Updates',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Dify v1.9 new launch',
|
||||
subtitle: 'New Agent node support',
|
||||
description: 'Build agent workflows with the new Agent node.',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
style_type: 'event',
|
||||
title: 'Duck Duck Go',
|
||||
sort: 2,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/duckduckgo.png',
|
||||
mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
|
||||
},
|
||||
link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
|
||||
alt_text: 'DuckDuckGo plugin',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const mockTrackEvent = vi.mocked(trackEvent)
|
||||
const mockTrackMarketplaceSiteEvent = vi.mocked(trackMarketplaceSiteEvent)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('HomeTrending', () => {
|
||||
it('renders and switches between the three API-backed banner layouts', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
expect(document.querySelector('[data-home-trending-carousel-root]')?.className).toMatch(
|
||||
/carouselRoot/,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument()
|
||||
const recommendationSlide = screen.getByRole('group', { name: 'Trending' })
|
||||
expect(
|
||||
within(recommendationSlide)
|
||||
.getAllByRole('link')
|
||||
.map((link) => link.getAttribute('aria-label')),
|
||||
).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument()
|
||||
const blogSlide = screen.getByRole('group', { name: 'Dify Updates' })
|
||||
const blogLink = within(blogSlide).getByRole('link', {
|
||||
name: 'plugin.marketplace.home.trendingReadMoreAbout',
|
||||
})
|
||||
expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog')
|
||||
expect(within(blogSlide).getAllByRole('link')).toHaveLength(1)
|
||||
expect(
|
||||
within(blogLink).getByRole('heading', { name: 'Dify v1.9 new launch' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks inactive standalone slides so mobile CSS can collapse mixed banner heights', () => {
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const recommendSlide = screen.getByRole('group', { name: 'Trending' })
|
||||
const blogSlide = document.querySelector(
|
||||
'[aria-roledescription="slide"][aria-label="Dify Updates"]',
|
||||
)
|
||||
const eventSlide = document.querySelector(
|
||||
'[aria-roledescription="slide"][aria-label="Duck Duck Go"]',
|
||||
)
|
||||
const eventLink = document.querySelector('a[aria-label="DuckDuckGo plugin"]')
|
||||
|
||||
expect(recommendSlide.className).toMatch(/slide/)
|
||||
expect(recommendSlide.className).not.toMatch(/slideInactive/)
|
||||
expect(blogSlide?.className).toMatch(/slideInactive/)
|
||||
expect(eventSlide?.className).toMatch(/slideInactive/)
|
||||
expect(recommendSlide.firstElementChild?.className).toMatch(/stackedSlide/)
|
||||
expect(blogSlide?.firstElementChild?.className).toMatch(/stackedSlide/)
|
||||
expect(eventLink?.className).toMatch(/imageSlide/)
|
||||
expect(eventLink?.querySelector('source')).toHaveAttribute('media', '(max-width: 879px)')
|
||||
expect(eventLink?.querySelector('source')?.getAttribute('srcset')).toContain(
|
||||
'duckduckgo-mobile.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the embedded event image breakpoint at 639px', () => {
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />)
|
||||
|
||||
expect(document.querySelector('a[aria-label="DuckDuckGo plugin"] source')).toHaveAttribute(
|
||||
'media',
|
||||
'(max-width: 639px)',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to desktop on the mobile source when an event banner has no mobile asset', () => {
|
||||
const eventWithoutMobile: PluginBanner = {
|
||||
id: 'event-desktop-only',
|
||||
style_type: 'event',
|
||||
title: 'Desktop Event',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/event-desktop.png',
|
||||
tablet: '/api/v1/banners/images/banners/event-tablet.png',
|
||||
},
|
||||
link: 'https://dify.ai/event',
|
||||
alt_text: 'Desktop event',
|
||||
},
|
||||
}
|
||||
|
||||
render(<HomeTrending banners={[eventWithoutMobile]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const eventLink = screen.getByRole('link', { name: 'Desktop event' })
|
||||
const sources = eventLink.querySelectorAll('source')
|
||||
|
||||
expect(sources[0]).toHaveAttribute('media', '(max-width: 879px)')
|
||||
expect(sources[0]?.getAttribute('srcset')).toContain('event-desktop.png')
|
||||
expect(sources[0]?.getAttribute('srcset')).not.toContain('event-tablet.png')
|
||||
expect(sources[1]).toHaveAttribute('media', '(min-width: 880px) and (max-width: 1023px)')
|
||||
expect(sources[1]?.getAttribute('srcset')).toContain('event-tablet.png')
|
||||
expect(eventLink.querySelector('img')?.getAttribute('src')).toContain('event-desktop.png')
|
||||
})
|
||||
|
||||
it('switches to the selected slide from the pagination with the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' })
|
||||
|
||||
duckDuckGoButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current')
|
||||
expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute(
|
||||
'aria-hidden',
|
||||
'false',
|
||||
)
|
||||
})
|
||||
|
||||
it('loops from the last banner to a visual clone before resetting to the first banner', () => {
|
||||
const animations: Array<{
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
onfinish: (() => void) | null
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
play: ReturnType<typeof vi.fn>
|
||||
}> = []
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
const animation = {
|
||||
cancel: vi.fn(),
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
}
|
||||
animations.push(animation)
|
||||
return animation as unknown as Animation
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
const track = document.querySelector('[data-carousel-track]')!
|
||||
|
||||
act(() => animations.at(-1)?.onfinish?.())
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
)
|
||||
expect(track).toHaveStyle({ transform: 'translate3d(-300%, 0, 0)' })
|
||||
expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
|
||||
|
||||
fireEvent.transitionEnd(track, { propertyName: 'transform' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
)
|
||||
expect(track).toHaveStyle({ transform: 'translate3d(-0%, 0, 0)', transition: 'none' })
|
||||
} finally {
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not reject when the autoplay animation is canceled on unmount', async () => {
|
||||
let rejectFinished: (reason: unknown) => void = () => {}
|
||||
const finished = new Promise<Animation>((_resolve, reject) => {
|
||||
rejectFinished = reject
|
||||
})
|
||||
const progressAnimation = {
|
||||
cancel: vi.fn(() => {
|
||||
rejectFinished(
|
||||
Object.assign(new Error('The animation was canceled.'), { name: 'AbortError' }),
|
||||
)
|
||||
}),
|
||||
onfinish: null,
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
finished,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
|
||||
try {
|
||||
const { unmount } = render(
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />,
|
||||
)
|
||||
|
||||
unmount()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(progressAnimation.cancel).toHaveBeenCalled()
|
||||
} finally {
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('toggles the carousel between paused and playing states', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const carousel = document.querySelector('[data-home-trending-carousel-root]')!
|
||||
const liveTrack = carousel.querySelector('[aria-live]')!
|
||||
expect(liveTrack).toHaveAttribute('aria-live', 'off')
|
||||
|
||||
const pauseButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
})
|
||||
expect(pauseButton).toHaveClass('bg-state-base-active')
|
||||
|
||||
pauseButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(liveTrack).toHaveAttribute('aria-live', 'polite')
|
||||
|
||||
const playButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPlay',
|
||||
})
|
||||
|
||||
playButton.focus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts with autoplay paused when reduced motion is enabled', () => {
|
||||
const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPlay',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
matchMedia.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps embedded autoplay paused until every pause reason is cleared', () => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn()
|
||||
const cancel = vi.fn()
|
||||
const progressAnimation = {
|
||||
cancel,
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause,
|
||||
play,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
const intersectionObservers: {
|
||||
callback: IntersectionObserverCallback
|
||||
options?: IntersectionObserverInit
|
||||
}[] = []
|
||||
class MockIntersectionObserver {
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
root: Element | Document | null
|
||||
rootMargin: string
|
||||
takeRecords = vi.fn(() => [])
|
||||
thresholds: readonly number[]
|
||||
unobserve = vi.fn()
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.root = options?.root ?? null
|
||||
this.rootMargin = options?.rootMargin ?? '0px'
|
||||
this.thresholds = Array.isArray(options?.threshold)
|
||||
? options.threshold
|
||||
: [options?.threshold ?? 0]
|
||||
intersectionObservers.push({ callback, options })
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
let reducedMotion = false
|
||||
let reducedMotionListener: (() => void) | undefined
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
get matches() {
|
||||
return reducedMotion
|
||||
},
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: (_event: string, listener: () => void) => {
|
||||
reducedMotionListener = listener
|
||||
},
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
const marketplaceContainer = document.createElement('div')
|
||||
marketplaceContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(marketplaceContainer)
|
||||
|
||||
const { unmount } = render(
|
||||
<HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />,
|
||||
{
|
||||
container: marketplaceContainer,
|
||||
},
|
||||
)
|
||||
const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')!
|
||||
const viewportObserver = intersectionObservers.find(
|
||||
(observer) => observer.options?.threshold === 0.25,
|
||||
)
|
||||
const setIntersectionRatio = (intersectionRatio: number) => {
|
||||
act(() => {
|
||||
viewportObserver?.callback(
|
||||
[
|
||||
{
|
||||
intersectionRatio,
|
||||
isIntersecting: intersectionRatio > 0,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(pause).toHaveBeenCalled()
|
||||
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.mouseEnter(carouselRoot)
|
||||
setIntersectionRatio(0)
|
||||
fireEvent.mouseLeave(carouselRoot)
|
||||
expect(play).toHaveBeenCalledOnce()
|
||||
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(2)
|
||||
|
||||
const playsBeforeFocus = play.mock.calls.length
|
||||
const focusTarget = carouselRoot.querySelector('a')!
|
||||
fireEvent.focusIn(focusTarget)
|
||||
setIntersectionRatio(0)
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeFocus)
|
||||
fireEvent.focusOut(focusTarget, { relatedTarget: null })
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus)
|
||||
|
||||
// Navigation controls sit inside the pause boundary, so focusing them
|
||||
// also stops the rotation.
|
||||
const playsBeforeControlFocus = play.mock.calls.length
|
||||
const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
|
||||
fireEvent.focusIn(paginationButton)
|
||||
setIntersectionRatio(0)
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeControlFocus)
|
||||
fireEvent.focusOut(paginationButton, { relatedTarget: null })
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeControlFocus)
|
||||
|
||||
const playsBeforeUserPause = play.mock.calls.length
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
|
||||
setIntersectionRatio(0)
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeUserPause)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause)
|
||||
|
||||
const playsBeforeVisibilityPause = play.mock.calls.length
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
})
|
||||
fireEvent(document, new Event('visibilitychange'))
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause)
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible',
|
||||
})
|
||||
fireEvent(document, new Event('visibilitychange'))
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause)
|
||||
|
||||
const playsBeforeReducedMotion = play.mock.calls.length
|
||||
reducedMotion = true
|
||||
reducedMotionListener?.()
|
||||
setIntersectionRatio(0)
|
||||
setIntersectionRatio(0.25)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion)
|
||||
|
||||
reducedMotion = false
|
||||
reducedMotionListener?.()
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion)
|
||||
|
||||
unmount()
|
||||
marketplaceContainer.remove()
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes autoplay after a pointer click on pagination without waiting for blur', async () => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn()
|
||||
const progressAnimation = {
|
||||
cancel: vi.fn(),
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause,
|
||||
play,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const carouselRoot = document.querySelector('[data-home-trending-carousel-root]')!
|
||||
const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
|
||||
|
||||
// Pointer activation hovers and focuses the control, which normally
|
||||
// pauses rotation until mouseleave/focusout.
|
||||
fireEvent.mouseEnter(carouselRoot)
|
||||
paginationButton.focus()
|
||||
fireEvent.focusIn(paginationButton)
|
||||
|
||||
const playsBeforeSelect = play.mock.calls.length
|
||||
await user.click(paginationButton)
|
||||
|
||||
expect(paginationButton).toHaveAttribute('aria-current', 'true')
|
||||
expect(document.activeElement).toBe(paginationButton)
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeSelect)
|
||||
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps autoplay paused when pagination is selected from the keyboard', async () => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn()
|
||||
const progressAnimation = {
|
||||
cancel: vi.fn(),
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause,
|
||||
play,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
|
||||
paginationButton.focus()
|
||||
fireEvent.focusIn(paginationButton)
|
||||
|
||||
const playsBeforeSelect = play.mock.calls.length
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(paginationButton).toHaveAttribute('aria-current', 'true')
|
||||
expect(document.activeElement).toBe(paginationButton)
|
||||
expect(play).toHaveBeenCalledTimes(playsBeforeSelect)
|
||||
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes autoplay when Play is activated without moving keyboard focus', async () => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn()
|
||||
const progressAnimation = {
|
||||
cancel: vi.fn(),
|
||||
finished: Promise.resolve(),
|
||||
onfinish: null,
|
||||
pause,
|
||||
play,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
})
|
||||
|
||||
// Focusing the toggle adds the implicit focus pause reason, then Enter
|
||||
// adds the explicit user pause.
|
||||
toggleButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pause).toHaveBeenCalled()
|
||||
|
||||
// Play must resume the rotation even though the button is still focused
|
||||
// (and would normally keep the focus pause reason active).
|
||||
const playsBeforePlay = play.mock.calls.length
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay)
|
||||
expect(document.activeElement).toBe(toggleButton)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends embedded cards without a delivery link to the marketplace site', () => {
|
||||
const bannerWithMixedLinks: PluginBanner = {
|
||||
id: 'recommend-mixed',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
display_name: 'Dropbox',
|
||||
link: 'https://external.example.com/dropbox',
|
||||
card_position: 0,
|
||||
},
|
||||
{
|
||||
// The console has no local /plugin route, so a card without a
|
||||
// delivery-provided link must open the marketplace detail page.
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/notion',
|
||||
display_name: 'Notion',
|
||||
link: '',
|
||||
card_position: 1,
|
||||
},
|
||||
{
|
||||
item_type: 'template',
|
||||
item_id: 'tpl-1',
|
||||
display_name: 'Support Bot',
|
||||
link: '',
|
||||
card_position: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<HomeTrending
|
||||
banners={[bannerWithMixedLinks]}
|
||||
isMarketplacePlatform={false}
|
||||
page="plugins"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://external.example.com/dropbox',
|
||||
)
|
||||
const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
|
||||
expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
|
||||
/^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,
|
||||
)
|
||||
expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
|
||||
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
|
||||
'href',
|
||||
'/templates?tid=tpl-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps the active slide when a refetch shrinks the banner list', async () => {
|
||||
const { rerender } = render(
|
||||
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
)
|
||||
|
||||
rerender(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('group', { name: 'Trending' })).not.toHaveAttribute('inert')
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no carousel when the API returns no banners', () => {
|
||||
render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('region', {
|
||||
name: 'plugin.marketplace.home.trendingTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks recommend card clicks as item clicks without a frame click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="templates" />)
|
||||
|
||||
await user.click(screen.getByRole('link', { name: 'Dropbox' }))
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_item_click', {
|
||||
banner_id: 'recommend',
|
||||
sort: 0,
|
||||
page: 'templates',
|
||||
language: 'en',
|
||||
style_type: 'recommend',
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
card_position: 0,
|
||||
theme_type: 'hottest',
|
||||
auto_batch_id: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalledWith('marketplace_banner_click', expect.anything())
|
||||
expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
|
||||
'marketplace_banner_click',
|
||||
expect.objectContaining({
|
||||
click_target: 'recommendation',
|
||||
item_id: 'langgenius/dropbox',
|
||||
item_type: 'plugin',
|
||||
item_name: 'Dropbox',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks whole-slide blog and event links as frame clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
|
||||
await user.click(
|
||||
screen.getByRole('link', { name: 'plugin.marketplace.home.trendingReadMoreAbout' }),
|
||||
)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', {
|
||||
banner_id: 'blog',
|
||||
sort: 1,
|
||||
page: 'plugins',
|
||||
language: 'en',
|
||||
style_type: 'blog',
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
await user.click(screen.getByRole('link', { name: 'DuckDuckGo plugin' }))
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', {
|
||||
banner_id: 'event',
|
||||
sort: 2,
|
||||
page: 'plugins',
|
||||
language: 'en',
|
||||
style_type: 'event',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render banner slides whose CMS link is not http(s) or relative', () => {
|
||||
const unsafeBlog: PluginBanner = {
|
||||
id: 'blog-unsafe',
|
||||
style_type: 'blog',
|
||||
title: 'Unsafe Updates',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Unsafe launch',
|
||||
subtitle: 'Should not be clickable',
|
||||
description: 'Reject javascript hrefs from CMS payloads.',
|
||||
link: 'javascript:alert(1)',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
}
|
||||
|
||||
render(<HomeTrending banners={[unsafeBlog]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Unsafe launch' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dual-writes banner impressions to Amplitude and marketplace site tracking', () => {
|
||||
vi.useFakeTimers()
|
||||
const observers: Array<{ callback: IntersectionObserverCallback }> = []
|
||||
class MockIntersectionObserver {
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
root: Element | Document | null = null
|
||||
rootMargin = '0px'
|
||||
takeRecords = () => []
|
||||
thresholds = [0.5]
|
||||
unobserve = vi.fn()
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
observers.push({ callback })
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
|
||||
try {
|
||||
const blogBanner = banners[1]
|
||||
if (!blogBanner) throw new Error('Expected a blog banner fixture')
|
||||
|
||||
render(<HomeTrending banners={[blogBanner]} isMarketplacePlatform page="plugins" />)
|
||||
|
||||
const observer = observers.at(-1)
|
||||
if (!observer) throw new Error('Expected IntersectionObserver to be registered')
|
||||
|
||||
act(() => {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
intersectionRatio: 0.5,
|
||||
isIntersecting: true,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
const properties = {
|
||||
banner_id: 'blog',
|
||||
sort: 1,
|
||||
page: 'plugins',
|
||||
language: 'en',
|
||||
style_type: 'blog',
|
||||
}
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_impression', properties)
|
||||
expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
|
||||
'marketplace_banner_impression',
|
||||
properties,
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeMarketplaceHref } from '../marketplace-href'
|
||||
|
||||
describe('sanitizeMarketplaceHref', () => {
|
||||
it('allows http(s) URLs and same-origin relative paths', () => {
|
||||
expect(sanitizeMarketplaceHref('https://dify.ai/blog')).toBe('https://dify.ai/blog')
|
||||
expect(sanitizeMarketplaceHref('http://localhost:3000/plugin/a/b')).toBe(
|
||||
'http://localhost:3000/plugin/a/b',
|
||||
)
|
||||
expect(sanitizeMarketplaceHref('/plugin/langgenius/dropbox')).toBe('/plugin/langgenius/dropbox')
|
||||
})
|
||||
|
||||
it('rejects blank values and non-http schemes', () => {
|
||||
expect(sanitizeMarketplaceHref('')).toBeNull()
|
||||
expect(sanitizeMarketplaceHref(' ')).toBeNull()
|
||||
expect(sanitizeMarketplaceHref('javascript:alert(1)')).toBeNull()
|
||||
expect(sanitizeMarketplaceHref('data:text/html,bad')).toBeNull()
|
||||
expect(sanitizeMarketplaceHref('mailto:test@example.com')).toBeNull()
|
||||
expect(sanitizeMarketplaceHref('//evil.example')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,83 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MarketplaceLiveSearch from '../marketplace-live-search'
|
||||
|
||||
const { mockReplace } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ahooks')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
describe('MarketplaceLiveSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('updates the active tab result route while the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch
|
||||
action="/templates/knowledge"
|
||||
language="en-US"
|
||||
placeholder="Search templates"
|
||||
query=""
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('searchbox'), 'legal')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the query without leaving the active plugin tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByRole('searchbox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves catalog filter params while the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<MarketplaceLiveSearch
|
||||
action="/templates/knowledge"
|
||||
placeholder="Search templates"
|
||||
query=""
|
||||
preserveParams={{ languages: ['ja'] }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('searchbox'), 'legal')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', {
|
||||
scroll: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,306 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeSearch from '../home-search'
|
||||
import { homeCatalogPinnedAtom } from '../home-sticky-state'
|
||||
import { HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete'
|
||||
|
||||
const { mockTemplateSearch } = vi.hoisted(() => ({
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ahooks')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return {
|
||||
...original,
|
||||
...createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/service/client')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
marketplaceQuery: {
|
||||
searchAdvanced: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'plugins', input],
|
||||
queryFn: () => ({ data: { plugins: [], total: 0 } }),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'templates', input],
|
||||
queryFn: () => mockTemplateSearch(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function StickyTemplateSearch() {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search templates"
|
||||
scope="templates"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PinnedHeaderState() {
|
||||
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-12 items-center bg-background-default">
|
||||
<span>Dify Marketplace</span>
|
||||
{isCatalogPinned && (
|
||||
<div role="tablist" aria-label="Header catalog tabs">
|
||||
Plugins and templates
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Marketplace search autocomplete layout', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear()
|
||||
mockTemplateSearch.mockReset()
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
})
|
||||
|
||||
it('keeps the pinned catalog layout stable while the results popup opens', async () => {
|
||||
await page.viewport(1280, 720)
|
||||
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<HomeStickyStateProvider>
|
||||
<div
|
||||
id={MARKETPLACE_CONTAINER_ID}
|
||||
data-marketplace-standalone
|
||||
data-testid="marketplace-scroll-container"
|
||||
style={{ height: 360, width: 1200, overflowY: 'auto' }}
|
||||
>
|
||||
<PinnedHeaderState />
|
||||
<div style={{ height: 180 }} aria-hidden />
|
||||
<HomeSearch enableSearchShortcut={false}>
|
||||
<StickyTemplateSearch />
|
||||
</HomeSearch>
|
||||
<HomeCatalogNavigation
|
||||
isMarketplacePlatform
|
||||
catalogCategories={<div role="group" aria-label="Template categories" />}
|
||||
catalogTabs={<div role="tablist" aria-label="Catalog tabs" />}
|
||||
/>
|
||||
<main aria-label="Template catalog" style={{ height: 900 }} />
|
||||
</div>
|
||||
</HomeStickyStateProvider>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const scrollContainer = screen.getByTestId('marketplace-scroll-container').element()
|
||||
scrollContainer.scrollTop = 300
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(requestAnimationFrame)
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Search templates' })
|
||||
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
|
||||
|
||||
const catalogNavigation = screen
|
||||
.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
.element()
|
||||
const scrollTopBefore = scrollContainer.scrollTop
|
||||
const inputTopBefore = input.element().getBoundingClientRect().top
|
||||
const navigationTopBefore = catalogNavigation.getBoundingClientRect().top
|
||||
|
||||
await input.fill('open')
|
||||
await expect.element(screen.getByText('No templates found')).toBeVisible()
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
|
||||
await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
|
||||
expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
|
||||
expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
|
||||
})
|
||||
|
||||
it('matches the reference grouped panel and compact result spacing', async () => {
|
||||
await page.viewport(1280, 720)
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
{
|
||||
id: 'template-2',
|
||||
template_name: 'Contract Reviewer',
|
||||
overview: 'Review contracts and identify risks.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 80,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<div className="w-[420px]">
|
||||
<StickyTemplateSearch />
|
||||
</div>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal')
|
||||
await expect.element(screen.getByText('Legal Research Agent')).toBeVisible()
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Search templates' }).element()
|
||||
const searchBox = input.parentElement!
|
||||
const list = screen.getByRole('listbox').element()
|
||||
const panel = list.parentElement!
|
||||
const templateGroup = screen.getByRole('group', { name: 'Templates' }).element()
|
||||
const firstItem = screen.getByRole('option', { name: /Legal Research Agent/ }).element()
|
||||
const lastItem = screen.getByRole('option', { name: /Contract Reviewer/ }).element()
|
||||
const panelStyle = getComputedStyle(panel)
|
||||
const listStyle = getComputedStyle(list)
|
||||
const templateGroupStyle = getComputedStyle(templateGroup)
|
||||
const firstItemStyle = getComputedStyle(firstItem)
|
||||
const statusRoots = screen.getByRole('status').all()
|
||||
const trailingStatus = statusRoots.at(-1)!.element()
|
||||
|
||||
expect(
|
||||
Math.abs(panel.getBoundingClientRect().width - searchBox.getBoundingClientRect().width),
|
||||
).toBeLessThanOrEqual(16)
|
||||
expect(panelStyle.paddingTop).toBe('0px')
|
||||
expect(panelStyle.paddingRight).toBe('0px')
|
||||
expect(panelStyle.paddingBottom).toBe('0px')
|
||||
expect(panelStyle.paddingLeft).toBe('0px')
|
||||
expect(panelStyle.borderRadius).toBe('12px')
|
||||
expect(listStyle.paddingTop).toBe('0px')
|
||||
expect(templateGroupStyle.paddingTop).toBe('4px')
|
||||
expect(templateGroupStyle.paddingRight).toBe('4px')
|
||||
expect(templateGroupStyle.paddingBottom).toBe('4px')
|
||||
expect(templateGroupStyle.paddingLeft).toBe('4px')
|
||||
expect(firstItemStyle.paddingTop).toBe('4px')
|
||||
expect(firstItemStyle.paddingRight).toBe('4px')
|
||||
expect(firstItemStyle.paddingBottom).toBe('4px')
|
||||
expect(firstItemStyle.paddingLeft).toBe('12px')
|
||||
expect(firstItemStyle.borderRadius).toBe('8px')
|
||||
expect(firstItemStyle.marginLeft).toBe('0px')
|
||||
expect(firstItemStyle.marginRight).toBe('0px')
|
||||
expect(trailingStatus.getBoundingClientRect().height).toBe(0)
|
||||
expect(
|
||||
panel.getBoundingClientRect().bottom - lastItem.getBoundingClientRect().bottom,
|
||||
).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('keeps result rows fully clickable without a persistent trailing arrow', async () => {
|
||||
await page.viewport(390, 844)
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<div className="w-full px-4">
|
||||
<StickyTemplateSearch />
|
||||
</div>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal')
|
||||
const result = screen.getByRole('option', { name: /Legal Research Agent/ })
|
||||
await expect.element(result).toBeVisible()
|
||||
|
||||
const resultElement = result.element()
|
||||
const resultRect = resultElement.getBoundingClientRect()
|
||||
const label = screen.getByText('Legal Research Agent').element()
|
||||
const labelRectBeforeHover = label.getBoundingClientRect()
|
||||
const trailingVisuals = Array.from(
|
||||
resultElement.querySelectorAll<HTMLElement>('[aria-hidden="true"]'),
|
||||
).filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.left >= resultRect.right - 40
|
||||
})
|
||||
|
||||
expect(trailingVisuals).toHaveLength(0)
|
||||
expect(getComputedStyle(resultElement).cursor).toBe('pointer')
|
||||
|
||||
const backgroundBeforeHover = getComputedStyle(resultElement).backgroundColor
|
||||
await result.hover()
|
||||
const labelRectAfterHover = label.getBoundingClientRect()
|
||||
|
||||
expect(getComputedStyle(resultElement).backgroundColor).not.toBe(backgroundBeforeHover)
|
||||
expect(labelRectAfterHover.left).toBeCloseTo(labelRectBeforeHover.left)
|
||||
expect(labelRectAfterHover.width).toBeCloseTo(labelRectBeforeHover.width)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,710 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import {
|
||||
MarketplaceSearchAutocomplete,
|
||||
MarketplaceSearchForm,
|
||||
} from '../marketplace-search-autocomplete'
|
||||
|
||||
const { debounceState, mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
|
||||
// Most tests bypass the debounce for simplicity; the debounce-window test
|
||||
// flips this on to exercise the real 300ms lag.
|
||||
debounceState: { useRealDebounce: false },
|
||||
mockAssign: vi.fn(),
|
||||
mockPluginSearch: vi.fn(),
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ahooks')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T, options?: { wait?: number }) =>
|
||||
debounceState.useRealDebounce ? original.useDebounce(value, options) : value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
loading: 'Loading',
|
||||
'marketplace.loadError': 'Failed to load. Please try again.',
|
||||
'marketplace.home.plugins': 'Plugins',
|
||||
'marketplace.home.templates': 'Templates',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceQuery: {
|
||||
searchAdvanced: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'plugins', input],
|
||||
queryFn: () => mockPluginSearch(input),
|
||||
}),
|
||||
},
|
||||
templateSearch: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['marketplace', 'templates', input],
|
||||
queryFn: () => mockTemplateSearch(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
describe('MarketplaceSearchAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAssign.mockReset()
|
||||
vi.spyOn(window.location, 'assign').mockImplementation(mockAssign)
|
||||
debounceState.useRealDebounce = false
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows template suggestions and keeps the route search form contract', async () => {
|
||||
let resolveTemplateSearch!: (value: unknown) => void
|
||||
const templateSearchPromise = new Promise((resolve) => {
|
||||
resolveTemplateSearch = resolve
|
||||
})
|
||||
mockTemplateSearch.mockReturnValue(templateSearchPromise)
|
||||
const templateSearchResponse = {
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/templates/knowledge"
|
||||
category="knowledge"
|
||||
language="en-US"
|
||||
locale="en-US"
|
||||
placeholder="Search all templates..."
|
||||
query=""
|
||||
scope="templates"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'legal')
|
||||
expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
|
||||
resolveTemplateSearch(templateSearchResponse)
|
||||
|
||||
expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Legal Research Agent'))
|
||||
expect(mockAssign).toHaveBeenCalledWith(
|
||||
'/template/dify/Legal%20Research%20Agent?templateId=template-1',
|
||||
)
|
||||
|
||||
expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
|
||||
expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q')
|
||||
expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text')
|
||||
expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1)
|
||||
expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US')
|
||||
expect(mockPluginSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows plugin suggestions while preserving the controlled search owner', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={(nextValue) => {
|
||||
onValueChange(nextValue)
|
||||
setValue(nextValue)
|
||||
}}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute(
|
||||
'src',
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`,
|
||||
)
|
||||
expect(onValueChange).toHaveBeenLastCalledWith('google')
|
||||
expect(mockTemplateSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('groups mixed suggestions and opens the selected result instead of viewing more', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins or templates"
|
||||
query=""
|
||||
scope="all"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
|
||||
const templateGroup = await screen.findByRole('group', { name: 'Templates' })
|
||||
const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
|
||||
expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
|
||||
expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Google Search'))
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the typed query on Enter without selecting a hovered suggestion', async () => {
|
||||
mockTemplateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [
|
||||
{
|
||||
id: 'template-1',
|
||||
template_name: 'Legal Research Agent',
|
||||
overview: 'Research legal questions with cited sources.',
|
||||
publisher_handle: 'dify',
|
||||
usage_count: 120,
|
||||
categories: ['knowledge'],
|
||||
icon: '📄',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_file_key: '',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/search/all"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins or templates"
|
||||
query=""
|
||||
scope="all"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'search')
|
||||
await user.hover(await screen.findByRole('option', { name: /Legal Research Agent/ }))
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('combobox')).toHaveValue('search')
|
||||
})
|
||||
|
||||
it('opens plugin detail when a suggestion is chosen', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/plugins"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins"
|
||||
query=""
|
||||
scope="plugins"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects a suggestion without submitting when the parent handles the result', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const onSuggestionSelect = vi.fn()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<form>
|
||||
<MarketplaceSearchAutocomplete
|
||||
inputName="q"
|
||||
locale="en-US"
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const { container } = render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
expect(onSuggestionSelect).toHaveBeenCalledOnce()
|
||||
expect(onSuggestionSelect.mock.calls[0]?.[0]).toMatchObject({
|
||||
kind: 'plugin',
|
||||
plugin: { name: 'google-search' },
|
||||
})
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection working for the highlighted suggestion', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MarketplaceSearchForm
|
||||
action="/plugins"
|
||||
locale="en-US"
|
||||
placeholder="Search plugins"
|
||||
query=""
|
||||
scope="plugins"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
container.querySelector('form')?.addEventListener('submit', handleSubmit)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search')
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands the selected plugin back to a creator-profile owner without submitting', async () => {
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const onSuggestionSelect = vi.fn()
|
||||
const handleSubmit = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
handleSubmit(event.nativeEvent)
|
||||
}}
|
||||
>
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
await user.click(await screen.findByText('Google Search'))
|
||||
|
||||
expect(onSuggestionSelect).toHaveBeenCalledWith({
|
||||
kind: 'plugin',
|
||||
plugin: expect.objectContaining({
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
}),
|
||||
})
|
||||
expect(handleSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('combobox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('does not offer the previous term suggestions while a new search is pending', async () => {
|
||||
const googleResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
mockPluginSearch.mockImplementation((input: { body: { query: string } }) => {
|
||||
if (input.body.query === 'google') return Promise.resolve(googleResponse)
|
||||
// Keep the follow-up term pending so stale suggestions would be visible
|
||||
// if the query still returned placeholder data.
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), ' drive')
|
||||
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not reopen after dismiss while a request is still pending', async () => {
|
||||
let resolvePluginSearch!: (value: unknown) => void
|
||||
mockPluginSearch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePluginSearch = resolve
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const pluginResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
<button type="button">Outside search</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Outside search' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Loading/)).not.toBeVisible()
|
||||
})
|
||||
|
||||
resolvePluginSearch(pluginResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPluginSearch).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the empty and status roots mounted when nothing matches', async () => {
|
||||
mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'zzzz')
|
||||
|
||||
expect(await screen.findByText('No integration found')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears suggestions while the edited value is still debouncing', async () => {
|
||||
debounceState.useRealDebounce = true
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
// Suggestions only appear once the real 300ms debounce has elapsed.
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
|
||||
// For the first 300ms after editing, the debounced term still points at
|
||||
// the old query; the previous suggestions must already be gone.
|
||||
await user.type(screen.getByRole('combobox'), ' drive')
|
||||
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,116 @@
|
||||
import { page } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { MARKETPLACE_CONTAINER_ID } from '../../constants'
|
||||
import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll'
|
||||
|
||||
const nextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
const SearchPage = ({ popup }: { popup?: boolean }) => (
|
||||
<>
|
||||
<div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}>
|
||||
<div style={{ height: 48, flexShrink: 0 }}>Header</div>
|
||||
<div style={{ height: 180, flexShrink: 0 }}>Hero</div>
|
||||
<div
|
||||
data-testid="search-root"
|
||||
style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }}
|
||||
>
|
||||
<input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ height: 900, flexShrink: 0 }}>Catalog</div>
|
||||
</div>
|
||||
{popup ? (
|
||||
<div
|
||||
data-testid="search-popup"
|
||||
style={{
|
||||
height: 80,
|
||||
overflowY: 'auto',
|
||||
position: 'fixed',
|
||||
top: 50,
|
||||
left: 100,
|
||||
width: 200,
|
||||
}}
|
||||
>
|
||||
Short
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
describe('Sticky search scroll guard', () => {
|
||||
it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
|
||||
const screen = await render(<SearchPage />)
|
||||
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const searchRoot = screen.getByTestId('search-root').element()
|
||||
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
|
||||
|
||||
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
|
||||
container.scrollTop = 400
|
||||
container.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
const scrollTopBefore = container.scrollTop
|
||||
HTMLInputElement.prototype.focus.call(input)
|
||||
await nextFrame()
|
||||
|
||||
expect(container.scrollTop).toBe(scrollTopBefore)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps visitor-initiated scroll after typing in the sticky search', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
|
||||
const screen = await render(<SearchPage />)
|
||||
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const searchRoot = screen.getByTestId('search-root').element()
|
||||
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
|
||||
|
||||
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
|
||||
container.scrollTop = 400
|
||||
container.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
input.focus()
|
||||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' }))
|
||||
await nextFrame()
|
||||
|
||||
expect(container.scrollTop).toBe(400)
|
||||
|
||||
container.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }))
|
||||
container.scrollTop = 520
|
||||
container.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
expect(container.scrollTop).toBe(520)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('scrolls the page when the visitor wheels over a portaled popup that cannot scroll', async () => {
|
||||
await page.viewport(1280, 900)
|
||||
|
||||
const screen = await render(<SearchPage popup />)
|
||||
const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
|
||||
const searchRoot = screen.getByTestId('search-root').element()
|
||||
const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
|
||||
const popup = screen.getByTestId('search-popup').element()
|
||||
|
||||
const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
|
||||
container.scrollTop = 400
|
||||
container.dispatchEvent(new Event('scroll'))
|
||||
await nextFrame()
|
||||
|
||||
input.focus()
|
||||
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' }))
|
||||
await nextFrame()
|
||||
|
||||
popup.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }))
|
||||
await nextFrame()
|
||||
|
||||
expect(container.scrollTop).toBe(520)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,137 @@
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { createElement, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useBannerViewability } from '../use-banner-viewability'
|
||||
|
||||
type ObserverRecord = {
|
||||
callback: IntersectionObserverCallback
|
||||
options?: IntersectionObserverInit
|
||||
}
|
||||
|
||||
let observers: ObserverRecord[] = []
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly scrollMargin = ''
|
||||
readonly thresholds: readonly number[]
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
takeRecords = () => []
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.root = options?.root ?? null
|
||||
this.rootMargin = options?.rootMargin ?? '0px'
|
||||
this.thresholds = Array.isArray(options?.threshold)
|
||||
? options.threshold
|
||||
: [options?.threshold ?? 0]
|
||||
observers.push({ callback, options })
|
||||
}
|
||||
}
|
||||
|
||||
function ViewabilityProbe({
|
||||
enabled = true,
|
||||
onImpression,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
onImpression: () => void
|
||||
}) {
|
||||
const targetRef = useRef<HTMLDivElement>(null)
|
||||
useBannerViewability(targetRef, onImpression, enabled)
|
||||
return createElement('div', { ref: targetRef, 'data-testid': 'banner-slide' })
|
||||
}
|
||||
|
||||
function triggerIntersection(intersectionRatio: number) {
|
||||
const observer = observers.at(-1)
|
||||
if (!observer) throw new Error('Expected IntersectionObserver to be registered')
|
||||
|
||||
act(() => {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
intersectionRatio,
|
||||
isIntersecting: intersectionRatio > 0,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useBannerViewability', () => {
|
||||
beforeEach(() => {
|
||||
observers = []
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('records one impression after the slide stays at least 50% visible for 1000ms', () => {
|
||||
const onImpression = vi.fn()
|
||||
render(createElement(ViewabilityProbe, { onImpression }))
|
||||
|
||||
triggerIntersection(0.5)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(onImpression).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(onImpression).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('records a second impression after the slide leaves and becomes viewable again', () => {
|
||||
const onImpression = vi.fn()
|
||||
render(createElement(ViewabilityProbe, { onImpression }))
|
||||
|
||||
triggerIntersection(0.8)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(onImpression).toHaveBeenCalledOnce()
|
||||
|
||||
triggerIntersection(0)
|
||||
triggerIntersection(0.6)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(onImpression).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not record an impression when the slide is visible for less than 1s', () => {
|
||||
const onImpression = vi.fn()
|
||||
render(createElement(ViewabilityProbe, { onImpression }))
|
||||
|
||||
triggerIntersection(0.9)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(999)
|
||||
})
|
||||
triggerIntersection(0)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(onImpression).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not record an impression when the visible ratio stays below 0.5', () => {
|
||||
const onImpression = vi.fn()
|
||||
render(createElement(ViewabilityProbe, { onImpression }))
|
||||
|
||||
triggerIntersection(0.49)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
expect(onImpression).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="brain-2-fill">
|
||||
<path id="Vector" d="M8.5 2C6.567 2 5 3.567 5 5.5C5 5.68016 5.01364 5.85714 5.03993 6.02997C3.32436 6.25523 2 7.72295 2 9.5C2 10.4793 2.40223 11.3647 3.05051 12C2.40223 12.6353 2 13.5207 2 14.5C2 15.9018 2.82359 17.1104 4.01353 17.6693C4.00457 17.7785 4 17.8888 4 18C4 20.2091 5.79086 22 8 22C9.19469 22 10.2671 21.4762 11 20.6458V3.05051C10.3647 2.40223 9.47934 2 8.5 2ZM13 3.05051V20.6458C13.7329 21.4762 14.8053 22 16 22C18.2091 22 20 20.2091 20 18C20 17.8888 19.9954 17.7785 19.9865 17.6693C21.1764 17.1104 22 15.9018 22 14.5C22 13.5207 21.5978 12.6353 20.9495 12C21.5978 11.3647 22 10.4793 22 9.5C22 7.72295 20.6756 6.25523 18.9601 6.02997C18.9864 5.85714 19 5.68016 19 5.5C19 3.567 17.433 2 15.5 2C14.5207 2 13.6353 2.40223 13 3.05051Z" fill="#0033FF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 960 B |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user