mirror of
https://github.com/langgenius/dify.git
synced 2026-02-22 19:15:47 +08:00
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:
@ -75,7 +75,6 @@ const ListWithCollection = (props: ListWithCollectionProps) => {
|
||||
itemKeyField="id"
|
||||
renderCard={renderTemplateCard}
|
||||
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
|
||||
viewMoreSearchTab="templates"
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user