feat: add template icon URL utility and integrate AppIcon component in template card and search dropdown for improved icon rendering

This commit is contained in:
yessenia
2026-02-12 01:41:58 +08:00
parent 8108c21d5b
commit 0db446b8ea
5 changed files with 275 additions and 71 deletions

View File

@ -75,7 +75,6 @@ const ListWithCollection = (props: ListWithCollectionProps) => {
itemKeyField="id"
renderCard={renderTemplateCard}
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
viewMoreSearchTab="templates"
cardContainerClassName={cardContainerClassName}
/>
)

View File

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

View File

@ -2,16 +2,17 @@
import type { Template } from '../types'
import { useLocale, useTranslation } from '#i18n'
import Image from 'next/image'
import Link from 'next/link'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import CornerMark from '@/app/components/plugins/card/base/corner-mark'
import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import { formatUsedCount } from '@/utils/template'
import { getMarketplaceUrl } from '@/utils/var'
import { getTemplateIconUrl } from '../utils'
type TemplateCardProps = {
template: Template
@ -21,31 +22,6 @@ type TemplateCardProps = {
// Number of tag icons to show before showing "+X"
const MAX_VISIBLE_DEPS_PLUGINS = 7
// Soft background color palette for avatar
const AVATAR_BG_COLORS = [
'bg-components-icon-bg-red-soft',
'bg-components-icon-bg-orange-dark-soft',
'bg-components-icon-bg-yellow-soft',
'bg-components-icon-bg-green-soft',
'bg-components-icon-bg-teal-soft',
'bg-components-icon-bg-blue-light-soft',
'bg-components-icon-bg-blue-soft',
'bg-components-icon-bg-indigo-soft',
'bg-components-icon-bg-violet-soft',
'bg-components-icon-bg-pink-soft',
]
// Simple hash function to get consistent color per template
const getAvatarBgClass = (id: string): string => {
let hash = 0
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return AVATAR_BG_COLORS[Math.abs(hash) % AVATAR_BG_COLORS.length]
}
const TemplateCardComponent = ({
template,
className,
@ -55,21 +31,7 @@ const TemplateCardComponent = ({
const { theme } = useTheme()
const { id, template_name, overview, icon, publisher_handle, usage_count, icon_background, deps_plugins, kind } = template
const isSandbox = kind === 'sandboxed'
const isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
const avatarBgStyle = useMemo(() => {
// If icon_background is provided (hex or rgba), use it directly
if (icon_background)
return { backgroundColor: icon_background }
return undefined
}, [icon_background])
const avatarBgClass = useMemo(() => {
// Only use class-based color if no inline style
if (icon_background)
return ''
return getAvatarBgClass(id)
}, [icon_background, id])
const iconUrl = getTemplateIconUrl(template)
const handleClick = useCallback(() => {
const url = getMarketplaceUrl(`/templates/${publisher_handle}/${template_name}`, {
@ -98,27 +60,13 @@ const TemplateCardComponent = ({
{/* Header */}
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
{/* Avatar */}
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-divider-regular p-1',
avatarBgClass,
)}
style={avatarBgStyle}
>
{isIconUrl
? (
<Image
src={icon}
alt={template_name}
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
)
: (
<span className="text-2xl leading-[1.2]">{icon || '📄'}</span>
)}
</div>
<AppIcon
size="large"
iconType={iconUrl ? 'image' : 'emoji'}
icon={iconUrl ? undefined : (icon || '📄')}
imageUrl={iconUrl || undefined}
background={icon_background || undefined}
/>
{/* Title */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<p className="system-md-medium truncate text-text-primary">{template_name}</p>

View File

@ -3,6 +3,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArrowRightLine } from '@remixicon/react'
import { Fragment } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Loading from '@/app/components/base/loading'
import { useCategories } from '@/app/components/plugins/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n'
@ -10,7 +11,7 @@ import { cn } from '@/utils/classnames'
import { formatUsedCount } from '@/utils/template'
import { getMarketplaceUrl } from '@/utils/var'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace, getTemplateIconUrl } from '../../utils'
const DROPDOWN_PANEL = 'w-[472px] max-h-[710px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
@ -175,18 +176,20 @@ function TemplatesSection({ templates, t }: {
const descriptionText = template.overview
const formattedUsedCount = formatUsedCount(template.usage_count, { precision: 0, rounding: 'floor' })
const usedLabel = t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })
const iconBgStyle = template.icon_background
? { backgroundColor: template.icon_background }
: undefined
const iconUrl = getTemplateIconUrl(template)
return (
<DropdownItem
key={template.id}
href={getMarketplaceUrl(`/templates/${template.publisher_handle}/${template.template_name}`, { templateId: template.id })}
icon={(
<div className="flex shrink-0 items-start py-1">
<IconBox shape="rounded-lg" style={iconBgStyle}>
<span className="text-xl leading-[1.2]">{template.icon || '📄'}</span>
</IconBox>
<AppIcon
size="small"
iconType={iconUrl ? 'image' : 'emoji'}
icon={iconUrl ? undefined : (template.icon || '📄')}
imageUrl={iconUrl || undefined}
background={template.icon_background || undefined}
/>
</div>
)}
>

View File

@ -58,6 +58,14 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
export const getTemplateIconUrl = (template: { id: string, icon?: string, icon_file_key?: string }): string => {
if (template.icon?.startsWith('http'))
return template.icon
if (template.icon_file_key)
return `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
return ''
}
export const getCreatorAvatarUrl = (uniqueHandle: string) => {
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
}