From 1bbbcfedf021a4dac2fedd62074f12ef71c3f3f9 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 31 Jan 2026 19:18:13 -0800 Subject: [PATCH] feat: add provider logo overlays to workflow template thumbnails (#8365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for overlaying provider logos on workflow template thumbnails at runtime. ## Changes - **What**: - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type - Create `LogoOverlay.vue` component for rendering positioned logos - Fetch logo index from `templates/index_logo.json` in store - Add `getLogoUrl` helper to `useTemplateWorkflows` composable - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog` ## Review Focus - Logo positioning uses Tailwind classes (e.g. `absolute bottom-2 right-2`) - Supports multiple logos per template with configurable size/opacity - Gracefully handles missing logos (returns empty string, renders nothing) - Templates must explicitly declare logos - no magic inference from models ## Dependencies Requires separate PR in workflow_templates repo to: 1. Update `index.schema.json` with logos definition 2. Add `logos` field to templates in `index.json` ## Screenshots (if applicable) image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b) by [Unito](https://www.unito.io) --------- Co-authored-by: Subagent 5 Co-authored-by: Amp --- .../widget/WorkflowTemplateSelectorDialog.vue | 6 + .../templates/thumbnails/LogoOverlay.test.ts | 157 ++++++++++++++++++ .../templates/thumbnails/LogoOverlay.vue | 117 +++++++++++++ src/locales/en/main.json | 3 + .../repositories/workflowTemplatesStore.ts | 51 +++++- .../templates/schemas/templateSchema.ts | 5 + .../workflow/templates/types/template.ts | 17 ++ 7 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 src/components/templates/thumbnails/LogoOverlay.test.ts create mode 100644 src/components/templates/thumbnails/LogoOverlay.vue create mode 100644 src/platform/workflow/templates/schemas/templateSchema.ts diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 8580e29f9..2ed71befc 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -256,6 +256,11 @@ " /> + ({ + useI18n: () => ({ + t: (key: string) => + key === 'templates.logoProviderSeparator' ? ' & ' : key, + locale: ref('en') + }) +})) + +type LogoOverlayProps = ComponentProps + +describe('LogoOverlay', () => { + function mockGetLogoUrl(provider: string) { + return `/logos/${provider}.png` + } + + function mountOverlay( + logos: LogoInfo[], + props: Partial = {} + ) { + 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') + }) + }) +}) diff --git a/src/components/templates/thumbnails/LogoOverlay.vue b/src/components/templates/thumbnails/LogoOverlay.vue new file mode 100644 index 000000000..3dc4846b4 --- /dev/null +++ b/src/components/templates/thumbnails/LogoOverlay.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 1e1b8d44f..71d243b40 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -966,6 +966,9 @@ "searchPlaceholder": "Search..." } }, + "templates": { + "logoProviderSeparator": " & " + }, "graphCanvasMenu": { "zoomIn": "Zoom In", "zoomOut": "Zoom Out", diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index f96872f79..463d76ec7 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -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, @@ -31,6 +33,7 @@ export const useWorkflowTemplatesStore = defineStore( const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({}) const coreTemplates = shallowRef([]) const englishTemplates = shallowRef([]) + const logoIndex = shallowRef({}) const isLoaded = ref(false) const knownTemplateNames = ref(new Set()) @@ -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) @@ -498,6 +504,36 @@ export const useWorkflowTemplatesStore = defineStore( } } + async function fetchLogoIndex(): Promise { + 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 @@ -534,7 +570,8 @@ export const useWorkflowTemplatesStore = defineStore( loadWorkflowTemplates, knownTemplateNames, getTemplateByName, - getEnglishMetadata + getEnglishMetadata, + getLogoUrl } } ) diff --git a/src/platform/workflow/templates/schemas/templateSchema.ts b/src/platform/workflow/templates/schemas/templateSchema.ts new file mode 100644 index 000000000..148bd6357 --- /dev/null +++ b/src/platform/workflow/templates/schemas/templateSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const zLogoIndex = z.record(z.string(), z.string()) + +export type LogoIndex = z.infer diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts index 1a9d839f8..2d52a5629 100644 --- a/src/platform/workflow/templates/types/template.ts +++ b/src/platform/workflow/templates/types/template.ts @@ -1,3 +1,16 @@ +export interface LogoInfo { + /** Provider name(s) matching index_logo.json. String for single, array for stacked logos. */ + provider: string | string[] + /** Custom label text. If omitted, defaults to provider names joined with " & " */ + label?: string + /** Gap between stacked logos in pixels. Negative for overlap effect. Default: -6 */ + gap?: number + /** Tailwind positioning classes */ + position?: string + /** Opacity 0-1, default 0.85 */ + opacity?: number +} + export interface TemplateInfo { name: string /** @@ -47,6 +60,10 @@ export interface TemplateInfo { * If not specified, the template will be included on all distributions. */ includeOnDistributions?: TemplateIncludeOnDistributionEnum[] + /** + * Logo overlays to display on the template thumbnail. + */ + logos?: LogoInfo[] } export enum TemplateIncludeOnDistributionEnum {