feat(marketplace): use stable template UUID links

This commit is contained in:
CodingOnStar committed 2026-09-10 15:14:50 +08:00
1 parent f67f8afbb5
commit 7f7e909826
8 files changed
+148 -65

No files matched your search

@@ -13,7 +13,13 @@ vi.mock('@/config', () => ({
// Mock var utils
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
getMarketplaceUrl: (path: string, params?: Record<string, string | undefined>) => {
const url = new URL(path, 'https://marketplace.dify.ai')
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== undefined) url.searchParams.set(key, value)
}
return url.toString()
},
}))
// Mock marketplace client
@@ -139,30 +145,68 @@ describe('getPluginDetailLinkInMarketplace', () => {
})
describe('getTemplateDetailLinkInMarketplace', () => {
it('should return the local template detail link', async () => {
it('keeps the same local link after a title change and prefers the unique handle', async () => {
const { getTemplateDetailLinkInMarketplace } = await import('../utils')
const template = {
id: '5d107b52-8d4d-447b-9bad-1c5c16b01721',
template_name: 'Legal Research Agent',
publisher_handle: 'Dify Display Name',
publisher_unique_handle: 'dify-unique',
}
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')
const link = getTemplateDetailLinkInMarketplace(template)
expect(link).toBe('/template/dify-unique/5d107b52-8d4d-447b-9bad-1c5c16b01721')
const renamedTemplate = { ...template, template_name: 'Renamed research agent' }
expect(getTemplateDetailLinkInMarketplace(renamedTemplate)).toBe(link)
})
it('should fall back to the unique publisher handle', async () => {
it('encodes both path segments and falls back to the publisher handle', async () => {
const { getTemplateDetailLinkInMarketplace } = await import('../utils')
expect(
getTemplateDetailLinkInMarketplace({
id: 'template-2',
template_name: 'Inbox',
publisher_handle: '',
publisher_unique_handle: 'langgenius',
id: 'template/one',
publisher_handle: 'Research / 团队',
publisher_unique_handle: '',
}),
).toBe('/template/langgenius/Inbox?templateId=template-2')
).toBe('/template/Research%20%2F%20%E5%9B%A2%E9%98%9F/template%2Fone')
})
it('uses the template fallback when publisher handles are absent', async () => {
const { getTemplateDetailLinkInMarketplace } = await import('../utils')
expect(getTemplateDetailLinkInMarketplace({ id: 'template-2' })).toBe(
'/template/template/template-2',
)
})
})
describe('getTemplateLinkInMarketplace', () => {
it('preserves embedded context while dropping legacy identity parameters', async () => {
const { getTemplateLinkInMarketplace } = await import('../utils')
const link = new URL(
getTemplateLinkInMarketplace(
{ id: 'template-1', publisher_handle: 'Display Name', publisher_unique_handle: 'dify' },
{
tid: 'legacy-short-id',
templateId: 'legacy-id',
creationType: 'templates',
language: 'zh-Hans',
theme: 'dark',
source: 'https://cloud.dify.ai',
view: 'modal',
},
),
)
expect(link.origin + link.pathname).toBe('https://marketplace.dify.ai/template/dify/template-1')
expect(Object.fromEntries(link.searchParams)).toEqual({
language: 'zh-Hans',
theme: 'dark',
source: 'https://cloud.dify.ai',
view: 'modal',
})
})
})
@@ -5,6 +5,7 @@ import type {
} from '@dify/contracts/marketplace'
import { describe, expect, it } from 'vite-plus/test'
import {
adaptCreations,
adaptCreatorProfile,
getStandaloneCreationHref,
normalizeCreatorSocialLink,
@@ -41,6 +42,7 @@ const template = {
icon_background: '#fff',
icon_file_key: '',
publisher_unique_handle: 'dify',
publisher_handle: 'Dify Display Name',
usage_count: 10,
categories: [],
deps_plugins: ['dify/search'],
@@ -100,8 +102,35 @@ describe('creator profile model', () => {
'/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',
'/template/dify/template%2Fone?language=zh-Hans',
)
expect(getStandaloneCreationHref(viewModel.creations[1]!)).toBe('/template/dify/template%2Fone')
})
it('uses stable template links for publisher handle fallbacks without exposing creator email', () => {
const creations = adaptCreations({
locale: 'en-US',
plugins: [],
templates: [
{ ...template, publisher_unique_handle: '', publisher_handle: '研究 / Team' },
{
...template,
publisher_unique_handle: '',
publisher_handle: '',
creator_email: 'private@example.com',
},
{ ...template, template_name: 'Renamed research template' },
],
resolvePluginIcon: () => '',
resolveTemplateIcon: () => '',
resolveDependencyIcon: () => '',
})
expect(creations.map((creation) => getStandaloneCreationHref(creation))).toEqual([
'/template/%E7%A0%94%E7%A9%B6%20%2F%20Team/template%2Fone',
'/template/template/template%2Fone',
'/template/dify/template%2Fone',
])
})
it('normalizes Unix-second, Unix-millisecond, and ISO timestamps', () => {
@@ -261,11 +261,7 @@ export const adaptCreations = ({
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'
const publisher = template.publisher_unique_handle || template.publisher_handle || 'template'
return {
id: `template:${template.id}`,
@@ -352,10 +348,6 @@ export const getStandaloneCreationHref = (creation: CreatorCreation, locale?: st
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()}`
const path = `/template/${encodeURIComponent(creation.target.publisher)}/${encodeURIComponent(creation.target.id)}`
return language ? `${path}?${language}` : path
}
@@ -843,7 +843,7 @@ describe('HomeTrending', () => {
)
})
it('shows the served author on recommend template cards the same way as plugins', () => {
it('links recommend template cards by ID with the served author or template fallback', () => {
const banner: PluginBanner = {
id: 'recommend-templates',
style_type: 'recommend',
@@ -877,6 +877,8 @@ describe('HomeTrending', () => {
const authored = screen.getByRole('link', { name: 'Go-to-Market' })
const anonymous = screen.getByRole('link', { name: 'Untitled Flow' })
expect(authored).toHaveAttribute('href', '/template/aisa-team/tpl-authored')
expect(anonymous).toHaveAttribute('href', '/template/template/tpl-anonymous')
expect(
within(authored).getByText('plugin.marketplace.home.trendingByCreator'),
).toBeInTheDocument()
@@ -139,9 +139,7 @@ describe('MarketplaceSearchAutocomplete', () => {
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(mockAssign).toHaveBeenCalledWith('/template/dify/template-1')
expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q')
@@ -29,7 +29,11 @@ import {
} from '@/utils/marketplace-site-track'
import MarketplaceDetailDialog from '../detail-dialog'
import TemplateDetailDialog from '../templates/template-detail-dialog'
import { getPluginLinkInMarketplace, getTemplateLinkInMarketplace } from '../utils'
import {
getPluginLinkInMarketplace,
getTemplateDetailLinkInMarketplace,
getTemplateLinkInMarketplace,
} from '../utils'
import background from './assets/background.webp'
import difyUpdatesArt from './assets/dify-updates-art.png'
import {
@@ -117,7 +121,11 @@ const getLocalCardHref = (card: BannerRecommendCard) => {
return `/plugin/${encodeURIComponent(identity.org)}/${encodeURIComponent(identity.name)}`
}
if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
if (card.item_type === 'template')
return getTemplateDetailLinkInMarketplace({
id: card.item_id,
publisher_unique_handle: card.creator || 'template',
})
return '/'
}
@@ -1,8 +1,8 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { Window } from 'happy-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import TemplateCard from '../template-card'
const { mockPush } = vi.hoisted(() => ({
@@ -13,12 +13,13 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('../../utils', () => ({
getTemplateLinkInMarketplace: (
currentTemplate: MarketplaceTemplate,
params: { language: string; source?: string; theme?: string; view: string },
) =>
`about:blank?templateId=${currentTemplate.id}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`,
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'dark' }),
}))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/base/app-icon', () => ({
@@ -39,17 +40,24 @@ const template: MarketplaceTemplate = {
}
describe('TemplateCard', () => {
const navigationSettings = (window as unknown as Window).happyDOM.settings.navigation
const originalDisableChildFrameNavigation = navigationSettings.disableChildFrameNavigation
beforeAll(() => {
navigationSettings.disableChildFrameNavigation = true
})
beforeEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
navigationSettings.disableChildFrameNavigation = originalDisableChildFrameNavigation
})
it('opens template detail before starting the Dify import flow', async () => {
const user = userEvent.setup()
render(
<ThemeProvider forcedTheme="dark">
<TemplateCard partnerText="Verified by a Dify partner" template={template} />
</ThemeProvider>,
)
render(<TemplateCard partnerText="Verified by a Dify partner" template={template} />)
expect(screen.queryByRole('link', { name: 'Campaign planner' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Campaign planner' }))
@@ -60,7 +68,15 @@ describe('TemplateCard', () => {
const frame = screen.getByTitle(
'Campaign planner · plugin.detailPanel.operation.detail',
) as HTMLIFrameElement
const marketplaceOrigin = new URL(frame.getAttribute('src')!, window.location.href).origin
const frameURL = new URL(frame.getAttribute('src')!, window.location.href)
expect(frameURL.pathname).toBe('/template/dify/template%2Fone')
expect(Object.fromEntries(frameURL.searchParams)).toEqual({
language: 'en-US',
source: window.location.origin,
theme: 'dark',
view: 'modal',
})
const marketplaceOrigin = frameURL.origin
const installRequest = {
type: 'dify-marketplace:install-template',
templateId: template.id,
+11 -17
View File
@@ -107,31 +107,25 @@ export const getPluginDetailLinkInMarketplace = (
}
export const getTemplateDetailLinkInMarketplace = (
template: Pick<
MarketplaceTemplate,
'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name'
>,
template: Pick<MarketplaceTemplate, 'id' | 'publisher_handle' | 'publisher_unique_handle'>,
) => {
const publisher = template.publisher_handle || template.publisher_unique_handle || 'template'
const search = new URLSearchParams({ templateId: template.id })
const publisher = template.publisher_unique_handle || template.publisher_handle || 'template'
return `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}?${search.toString()}`
return `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.id)}`
}
export const getTemplateLinkInMarketplace = (
template: Pick<
MarketplaceTemplate,
'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name'
>,
template: Pick<MarketplaceTemplate, 'id' | 'publisher_handle' | 'publisher_unique_handle'>,
params?: Record<string, string | undefined>,
) => {
const publisher = template.publisher_handle || template.publisher_unique_handle || 'template'
const path = `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}`
const {
tid: _tid,
templateId: _templateId,
creationType: _creationType,
...detailParams
} = params ?? {}
return getMarketplaceUrl(path, {
...params,
templateId: template.id,
})
return getMarketplaceUrl(getTemplateDetailLinkInMarketplace(template), detailParams)
}
export const getMarketplaceCategoryUrl = (