Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fb9bc50
feat: add provider logo overlays to workflow template thumbnails
Jan 28, 2026
9c5f482
style: update logo overlay to pill badge with provider name
Jan 29, 2026
0c5b97e
test: update LogoOverlay tests for pill badge design
Jan 29, 2026
019262b
fix: use stable provider key for logo v-for loop
Jan 29, 2026
85a7d19
fix: validate logo index entries before building URLs
Jan 29, 2026
d5166a0
test: refactor LogoOverlay tests to focus on behavior
Jan 29, 2026
fc36229
Revert "fix: validate logo index entries before building URLs"
Jan 29, 2026
a51a93c
refactor: use function declaration for mockGetLogoUrl
Jan 30, 2026
31ea8e4
test: remove Tailwind class selector in favor of behavioral assertion
Jan 30, 2026
d77fa09
refactor: directly re-export getLogoUrl from store
Jan 30, 2026
467a571
refactor: use axios for fetchLogoIndex consistent with getCoreWorkflo…
Jan 30, 2026
4b8edbf
feat: support stacked logos with overlapping design
christian-byrne Jan 30, 2026
b998a5c
fix: address CodeRabbit review feedback
christian-byrne Jan 31, 2026
edd357c
fix: address additional CodeRabbit review feedback
christian-byrne Jan 31, 2026
0310759
fix: use Intl.ListFormat for localized provider labels and type test …
christian-byrne Jan 31, 2026
00468ca
refactor: access getLogoUrl directly from store instead of re-exporting
christian-byrne Feb 1, 2026
95d254b
refactor: use zod schema for LogoIndex validation
christian-byrne Feb 1, 2026
ee28040
fix: restore accidentally removed comment
christian-byrne Feb 1, 2026
90a203e
refactor: address CodeRabbit review comments
christian-byrne Feb 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@
"
/>
</template>
<LogoOverlay
v-if="template.logos?.length"
:logos="template.logos"
:get-logo-url="workflowTemplatesStore.getLogoUrl"
/>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 m-auto h-12 w-12"
Expand Down Expand Up @@ -392,6 +397,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
Expand Down
157 changes: 157 additions & 0 deletions src/components/templates/thumbnails/LogoOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { mount } from '@vue/test-utils'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resolve the ESLint no-unresolved error for @vue/test-utils.

ESLint reports import-x/no-unresolved; ensure the dependency is installed and the resolver is configured for the workspace.

#!/bin/bash
set -euo pipefail

# Check package.json files for `@vue/test-utils` dependency
fd package.json -t f -E node_modules -E dist -E build | while read -r f; do
  echo "== $f =="
  rg -n '"@vue/test-utils"' "$f" || true
done

# Check eslint resolver configuration
rg -n "import/resolver|alias|tsconfig" .eslintrc* package.json
🧰 Tools
🪛 ESLint

[error] 1-1: Unable to resolve path to module '@vue/test-utils'.

(import-x/no-unresolved)

🤖 Prompt for AI Agents
In `@src/components/templates/thumbnails/LogoOverlay.test.ts` at line 1, ESLint
flags the import of `@vue/test-utils` as unresolved in LogoOverlay.test.ts (the
`import { mount } from '@vue/test-utils'` line); install the correct package
(e.g., add `@vue/test-utils` to devDependencies for the project's Vue version) and
ensure your import resolver is configured (update ESLint settings such as
import/resolver in .eslintrc or tsconfig/paths so the workspace resolver
recognizes node_modules and any path aliases); after installing, run the
provided verification script to confirm package.json entries include
"@vue/test-utils" and that import/resolver or tsconfig alias settings are
present.

import type { ComponentProps } from 'vue-component-type-helpers'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'

import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'

vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) =>
key === 'templates.logoProviderSeparator' ? ' & ' : key,
locale: ref('en')
})
}))

type LogoOverlayProps = ComponentProps<typeof LogoOverlay>

describe('LogoOverlay', () => {
function mockGetLogoUrl(provider: string) {
return `/logos/${provider}.png`
}

function mountOverlay(
logos: LogoInfo[],
props: Partial<LogoOverlayProps> = {}
) {
return mount(LogoOverlay, {
props: {
logos,
getLogoUrl: mockGetLogoUrl,
...props
}
})
}

it('renders nothing when logos array is empty', () => {
const wrapper = mountOverlay([])
expect(wrapper.findAll('img')).toHaveLength(0)
})

it('renders a single logo with correct src and alt', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/logos/Google.png')
expect(img.attributes('alt')).toBe('Google')
})

it('renders multiple separate logo entries', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' },
{ provider: 'Stability' }
])
expect(wrapper.findAll('img')).toHaveLength(3)
})

it('displays provider name as label for single provider', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const span = wrapper.find('span')
expect(span.text()).toBe('Google')
})

it('images are not draggable', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
})

it('filters out logos with empty URLs', () => {
function getLogoUrl(provider: string) {
return provider === 'Google' ? '/logos/Google.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
})

it('renders one logo per unique provider', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' }
])
expect(wrapper.findAll('img')).toHaveLength(2)
})

describe('stacked logos', () => {
it('renders multiple providers as stacked overlapping logos', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('alt')).toBe('WaveSpeed')
expect(images[1].attributes('alt')).toBe('Hunyuan')
})

it('joins provider names with locale-aware conjunction for default label', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const span = wrapper.find('span')
expect(span.text()).toBe('WaveSpeed and Hunyuan')
})

it('uses custom label when provided', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
])
const span = wrapper.find('span')
expect(span.text()).toBe('Custom Label')
})

it('applies negative gap for overlap effect', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -8px')
})

it('applies default gap when not specified', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -6px')
})

it('filters out invalid providers from stacked logos', () => {
function getLogoUrl(provider: string) {
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
expect(wrapper.find('span').text()).toBe('WaveSpeed')
})
})

describe('error handling', () => {
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = wrapper.findAll('[data-testid="logo-img"]')
expect(images).toHaveLength(2)

await images[0].trigger('error')
await nextTick()

const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
expect(remainingImages).toHaveLength(2)
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
})
})
})
117 changes: 117 additions & 0 deletions src/components/templates/thumbnails/LogoOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<template>
<div
v-for="logo in validLogos"
:key="logo.key"
:class="
cn('pointer-events-none absolute z-10', logo.position ?? defaultPosition)
"
>
<div
v-show="!hasAllFailed(logo.providers)"
data-testid="logo-pill"
class="flex items-center gap-1.5 rounded-full bg-black/20 py-1 pr-2"
:style="{ opacity: logo.opacity ?? 0.85 }"
>
<div class="ml-0.5 flex items-center">
<img
v-for="(provider, providerIndex) in logo.providers"
:key="provider"
data-testid="logo-img"
:src="logo.urls[providerIndex]"
:alt="provider"
class="h-6 w-6 rounded-full border-2 border-white object-cover"
:class="{ relative: providerIndex > 0 }"
:style="
providerIndex > 0 ? { marginLeft: `${logo.gap ?? -6}px` } : {}
"
draggable="false"
@error="onImageError(provider)"
/>
</div>
<span class="text-sm font-medium text-white">
{{ logo.label }}
</span>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import type { LogoInfo } from '@/platform/workflow/templates/types/template'
import { cn } from '@/utils/tailwindUtil'

const { t, locale } = useI18n()

function formatProviderList(providers: string[]): string {
const localeValue = String(locale.value)
try {
return new Intl.ListFormat(localeValue, {
style: 'long',
type: 'conjunction'
}).format(providers)
} catch {
return providers.join(t('templates.logoProviderSeparator'))
}
}

const {
logos,
getLogoUrl,
defaultPosition = 'top-2 left-2'
} = defineProps<{
logos: LogoInfo[]
getLogoUrl: (provider: string) => string
defaultPosition?: string
}>()

const failedLogos = ref(new Set<string>())

function onImageError(provider: string) {
failedLogos.value = new Set([...failedLogos.value, provider])
}

function hasAllFailed(providers: string[]): boolean {
return providers.every((p) => failedLogos.value.has(p))
}

interface ValidatedLogo {
key: string
providers: string[]
urls: string[]
label: string
position: string | undefined
opacity: number | undefined
gap: number | undefined
}

const validLogos = computed<ValidatedLogo[]>(() => {
const result: ValidatedLogo[] = []

logos.forEach((logo, index) => {
const providers = Array.isArray(logo.provider)
? logo.provider
: [logo.provider]
const urls = providers.map((p) => getLogoUrl(p))
const validProviders = providers.filter((_, i) => urls[i])
const validUrls = urls.filter((url) => url)

if (validProviders.length === 0) return

const providerKey = validProviders.join('-')
const layoutKey = `${logo.position ?? ''}-${logo.opacity ?? ''}-${logo.gap ?? ''}`
result.push({
key: providerKey ? `${providerKey}-${layoutKey}` : `logo-${index}`,
providers: validProviders,
urls: validUrls,
label: logo.label ?? formatProviderList(validProviders),
position: logo.position,
opacity: logo.opacity,
gap: logo.gap
})
})

return result
})
</script>
3 changes: 3 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,9 @@
"searchPlaceholder": "Search..."
}
},
"templates": {
"logoProviderSeparator": " & "
},
"graphCanvasMenu": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'

import { zLogoIndex } from '../schemas/templateSchema'
import type { LogoIndex } from '../schemas/templateSchema'
import type {
TemplateGroup,
TemplateInfo,
Expand All @@ -31,6 +33,7 @@ export const useWorkflowTemplatesStore = defineStore(
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const englishTemplates = shallowRef<WorkflowTemplates[]>([])
const logoIndex = shallowRef<LogoIndex>({})
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set<string>())

Expand Down Expand Up @@ -475,15 +478,18 @@ export const useWorkflowTemplatesStore = defineStore(
customTemplates.value = await api.getWorkflowTemplates()
const locale = i18n.global.locale.value

const [coreResult, englishResult] = await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([])
])
const [coreResult, englishResult, logoIndexResult] =
await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
])

coreTemplates.value = coreResult
englishTemplates.value = englishResult
logoIndex.value = logoIndexResult

const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
Expand All @@ -498,6 +504,36 @@ export const useWorkflowTemplatesStore = defineStore(
}
}

async function fetchLogoIndex(): Promise<LogoIndex> {
try {
const response = await api.fetchApi('/templates/index_logo.json')
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) return {}
const data = await response.json()
const result = zLogoIndex.safeParse(data)
return result.success ? result.data : {}
} catch {
return {}
}
}

function getLogoUrl(provider: string): string {
const logoPath = logoIndex.value[provider]
if (!logoPath) return ''

// Validate path to prevent directory traversal and ensure safe file extensions
const safePathPattern = /^[a-zA-Z0-9_\-./]+\.(png|jpg|jpeg|svg|webp)$/i
if (
!safePathPattern.test(logoPath) ||
logoPath.includes('..') ||
logoPath.startsWith('/')
) {
return ''
}

return api.fileURL(`/templates/${logoPath}`)
}

function getEnglishMetadata(templateName: string): {
tags?: string[]
category?: string
Expand Down Expand Up @@ -534,7 +570,8 @@ export const useWorkflowTemplatesStore = defineStore(
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName,
getEnglishMetadata
getEnglishMetadata,
getLogoUrl
}
}
)
Loading