Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
174 changes: 174 additions & 0 deletions src/components/templates/thumbnails/LogoOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { mount } from '@vue/test-utils'

Copy link
Copy Markdown
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 } from 'vue'
import { describe, expect, it } from 'vitest'

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

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('hides logo pill when all provider images fail to load', async () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('[data-testid="logo-img"]')
await img.trigger('error')
await nextTick()
const pill = wrapper.find('[data-testid="logo-pill"]')
expect(pill.attributes('style')).toContain('display: none')
})

it('keeps logo visible when only some images fail in stacked logos', async () => {
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = wrapper.findAll('[data-testid="logo-img"]')
await images[0].trigger('error')
await nextTick()
const pill = wrapper.find('[data-testid="logo-pill"]')
expect(pill.attributes('style')).not.toContain('display: none')
})
})

describe('styling', () => {
it('applies default opacity of 0.85', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const pill = wrapper.find('[data-testid="logo-pill"]')
expect(pill.attributes('style')).toContain('opacity: 0.85')
})

it('applies custom opacity', () => {
const wrapper = mountOverlay([{ provider: 'Google', opacity: 0.5 }])
const pill = wrapper.find('[data-testid="logo-pill"]')
expect(pill.attributes('style')).toContain('opacity: 0.5')
})

it('logos have border styling for stacking visibility', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('[data-testid="logo-img"]')
expect(img.exists()).toBe(true)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
113 changes: 113 additions & 0 deletions src/components/templates/thumbnails/LogoOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 { i18n } from '@/i18n'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
import { cn } from '@/utils/tailwindUtil'

function formatProviderList(providers: string[]): string {
const locale = String(i18n.global.locale.value)
try {
return new Intl.ListFormat(locale, {
style: 'long',
type: 'conjunction'
}).format(providers)
} catch {
return providers.join(' & ')
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

result.push({
key: validProviders.join('-') || `logo-${index}`,
providers: validProviders,
urls: validUrls,
label: logo.label ?? formatProviderList(validProviders),
position: logo.position,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
opacity: logo.opacity,
gap: logo.gap
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

return result
})
</script>
Loading