-
Notifications
You must be signed in to change notification settings - Fork 516
feat: add provider logo overlays to workflow template thumbnails #8365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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
9c5f482
style: update logo overlay to pill badge with provider name
0c5b97e
test: update LogoOverlay tests for pill badge design
019262b
fix: use stable provider key for logo v-for loop
85a7d19
fix: validate logo index entries before building URLs
d5166a0
test: refactor LogoOverlay tests to focus on behavior
fc36229
Revert "fix: validate logo index entries before building URLs"
a51a93c
refactor: use function declaration for mockGetLogoUrl
31ea8e4
test: remove Tailwind class selector in favor of behavioral assertion
d77fa09
refactor: directly re-export getLogoUrl from store
467a571
refactor: use axios for fetchLogoIndex consistent with getCoreWorkflo…
4b8edbf
feat: support stacked logos with overlapping design
christian-byrne b998a5c
fix: address CodeRabbit review feedback
christian-byrne edd357c
fix: address additional CodeRabbit review feedback
christian-byrne 0310759
fix: use Intl.ListFormat for localized provider labels and type test …
christian-byrne 00468ca
refactor: access getLogoUrl directly from store instead of re-exporting
christian-byrne 95d254b
refactor: use zod schema for LogoIndex validation
christian-byrne ee28040
fix: restore accidentally removed comment
christian-byrne 90a203e
refactor: address CodeRabbit review comments
christian-byrne File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
157 changes: 157 additions & 0 deletions
157
src/components/templates/thumbnails/LogoOverlay.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { mount } from '@vue/test-utils' | ||
| 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') | ||
| }) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
| }) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide 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 { 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')) | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide 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 | ||
|
|
||
| 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, | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| opacity: logo.opacity, | ||
| gap: logo.gap | ||
| }) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
|
||
| return result | ||
| }) | ||
| </script> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolve the ESLint
no-unresolvederror for@vue/test-utils.ESLint reports
import-x/no-unresolved; ensure the dependency is installed and the resolver is configured for the workspace.🧰 Tools
🪛 ESLint
[error] 1-1: Unable to resolve path to module '@vue/test-utils'.
(import-x/no-unresolved)
🤖 Prompt for AI Agents