feat: implement dynamic plugin card icon URL generation

Added a utility function to generate plugin card icon URLs based on the plugin's source and workspace context. Updated the Card component to utilize this function for determining the correct icon source. Enhanced unit tests to verify the correct URL generation for both marketplace and package icons.
This commit is contained in:
CodingOnStar
2026-03-12 14:57:51 +08:00
parent 339a8ca057
commit c167ee199c
3 changed files with 73 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import type { Plugin } from '../../types'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../../types'
import Card from '../index'
@ -40,6 +41,12 @@ vi.mock('@/utils/format', () => ({
formatNumber: (num: number) => num.toLocaleString(),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (value: { currentWorkspace: { id: string } }) => string) => selector({
currentWorkspace: { id: 'workspace-123' },
}),
}))
vi.mock('@/utils/mcp', () => ({
shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗',
}))
@ -189,6 +196,36 @@ describe('Card', () => {
expect(iconElement).toBeInTheDocument()
})
it('should normalize package icon filenames to workspace icon urls', () => {
const plugin = createMockPlugin({
from: 'package',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=custom-icon.png)`,
})
})
it('should normalize marketplace icon filenames to marketplace icon urls', () => {
const plugin = createMockPlugin({
from: 'marketplace',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon)`,
})
})
it('should use icon_dark when theme is dark and icon_dark is provided', () => {
// Set theme to dark
mockTheme = 'dark'

View File

@ -3,6 +3,7 @@ import type { Plugin } from '../types'
import { useTranslation } from '#i18n'
import { RiAlertFill } from '@remixicon/react'
import * as React from 'react'
import { useSelector } from '@/context/app-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import {
@ -14,6 +15,7 @@ import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified'
import Icon from '../card/base/card-icon'
import { useCategories } from '../hooks'
import { getPluginCardIconUrl } from '../utils'
import CornerMark from './base/corner-mark'
import Description from './base/description'
import OrgInfo from './base/org-info'
@ -50,9 +52,14 @@ const Card = ({
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
const { theme } = useTheme()
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },
theme === Theme.dark && icon_dark ? icon_dark : icon,
currentWorkspaceId,
)
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : ''
const isPartner = badges.includes('partner')
@ -101,7 +108,7 @@ const Card = ({
&& (
<div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:bottom-0 after:left-0 after:right-0 after:top-0 after:bg-toast-warning-bg after:opacity-40">
<RiAlertFill className="h-3 w-3 shrink-0 text-text-warning-secondary" />
<p className="system-xs-regular z-10 grow text-text-secondary">
<p className="z-10 grow text-text-secondary system-xs-regular">
{t('installModal.installWarning', { ns: 'plugin' })}
</p>
</div>

View File

@ -1,7 +1,9 @@
import type {
TagKey,
} from './constants'
import type { Plugin } from './types'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import {
categoryKeys,
tagKeys,
@ -14,3 +16,27 @@ export const getValidTagKeys = (tags: TagKey[]) => {
export const getValidCategoryKeys = (category?: string) => {
return categoryKeys.find(key => key === category)
}
const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value)
export const getPluginCardIconUrl = (
plugin: Pick<Plugin, 'from' | 'name' | 'org' | 'type'>,
icon: string | undefined,
tenantId: string,
) => {
if (!icon)
return ''
if (hasUrlProtocol(icon) || icon.startsWith('/'))
return icon
if (plugin.from === 'marketplace') {
const basePath = plugin.type === 'bundle' ? 'bundles' : 'plugins'
return `${MARKETPLACE_API_PREFIX}/${basePath}/${plugin.org}/${plugin.name}/icon`
}
if (!tenantId)
return icon
return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${icon}`
}