Skip to content

Commit 69b534b

Browse files
christian-byrnegithub-actions
andauthored
[UI] Improve template card spacing and responsive image display (#3930)
Co-authored-by: github-actions <[email protected]>
1 parent 2acb2ac commit 69b534b

21 files changed

+899
-19
lines changed

browser_tests/tests/templates.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,136 @@ test.describe('Templates', () => {
142142
// Expect the title to be used as fallback for the template categories
143143
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
144144
})
145+
146+
test('template cards are dynamically sized and responsive', async ({
147+
comfyPage
148+
}) => {
149+
// Open templates dialog
150+
await comfyPage.executeCommand('Comfy.BrowseTemplates')
151+
await expect(comfyPage.templates.content).toBeVisible()
152+
153+
// Wait for at least one template card to appear
154+
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
155+
timeout: 5000
156+
})
157+
158+
// Take snapshot of the template grid
159+
const templateGrid = comfyPage.templates.content.locator('.grid').first()
160+
await expect(templateGrid).toBeVisible()
161+
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
162+
163+
// Check cards at mobile viewport size
164+
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
165+
await expect(templateGrid).toBeVisible()
166+
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
167+
168+
// Check cards at tablet size
169+
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
170+
await expect(templateGrid).toBeVisible()
171+
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
172+
})
173+
174+
test('hover effects work on template cards', async ({ comfyPage }) => {
175+
// Open templates dialog
176+
await comfyPage.executeCommand('Comfy.BrowseTemplates')
177+
await expect(comfyPage.templates.content).toBeVisible()
178+
179+
// Get a template card
180+
const firstCard = comfyPage.page.locator('.template-card').first()
181+
await expect(firstCard).toBeVisible({ timeout: 5000 })
182+
183+
// Take snapshot before hover
184+
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
185+
186+
// Hover over the card
187+
await firstCard.hover()
188+
189+
// Take snapshot after hover to verify hover effect
190+
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
191+
})
192+
193+
test('template cards descriptions adjust height dynamically', async ({
194+
comfyPage
195+
}) => {
196+
// Setup test by intercepting templates response to inject cards with varying description lengths
197+
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
198+
const response = [
199+
{
200+
moduleName: 'default',
201+
title: 'Test Templates',
202+
type: 'image',
203+
templates: [
204+
{
205+
name: 'short-description',
206+
title: 'Short Description',
207+
mediaType: 'image',
208+
mediaSubtype: 'webp',
209+
description: 'This is a short description.'
210+
},
211+
{
212+
name: 'medium-description',
213+
title: 'Medium Description',
214+
mediaType: 'image',
215+
mediaSubtype: 'webp',
216+
description:
217+
'This is a medium length description that should take up two lines on most displays.'
218+
},
219+
{
220+
name: 'long-description',
221+
title: 'Long Description',
222+
mediaType: 'image',
223+
mediaSubtype: 'webp',
224+
description:
225+
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
226+
}
227+
]
228+
}
229+
]
230+
await route.fulfill({
231+
status: 200,
232+
body: JSON.stringify(response),
233+
headers: {
234+
'Content-Type': 'application/json',
235+
'Cache-Control': 'no-store'
236+
}
237+
})
238+
})
239+
240+
// Mock the thumbnail images to avoid 404s
241+
await comfyPage.page.route('**/templates/**.webp', async (route) => {
242+
const headers = {
243+
'Content-Type': 'image/webp',
244+
'Cache-Control': 'no-store'
245+
}
246+
await route.fulfill({
247+
status: 200,
248+
path: 'browser_tests/assets/example.webp',
249+
headers
250+
})
251+
})
252+
253+
// Open templates dialog
254+
await comfyPage.executeCommand('Comfy.BrowseTemplates')
255+
await expect(comfyPage.templates.content).toBeVisible()
256+
257+
// Verify cards are visible with varying content lengths
258+
await expect(
259+
comfyPage.page.getByText('This is a short description.')
260+
).toBeVisible({ timeout: 5000 })
261+
await expect(
262+
comfyPage.page.getByText('This is a medium length description')
263+
).toBeVisible({ timeout: 5000 })
264+
await expect(
265+
comfyPage.page.getByText('This is a much longer description')
266+
).toBeVisible({ timeout: 5000 })
267+
268+
// Take snapshot of a grid with specific cards
269+
const templateGrid = comfyPage.templates.content
270+
.locator('.grid:has-text("Short Description")')
271+
.first()
272+
await expect(templateGrid).toBeVisible()
273+
await expect(templateGrid).toHaveScreenshot(
274+
'template-grid-varying-content.png'
275+
)
276+
})
145277
})
81.3 KB
Loading
77.6 KB
Loading
193 KB
Loading
117 KB
Loading
245 KB
Loading
79.3 KB
Loading
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { mount } from '@vue/test-utils'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { ref } from 'vue'
4+
5+
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
6+
import { TemplateInfo } from '@/types/workflowTemplateTypes'
7+
8+
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
9+
default: {
10+
name: 'AudioThumbnail',
11+
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
12+
props: ['src']
13+
}
14+
}))
15+
16+
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
17+
default: {
18+
name: 'CompareSliderThumbnail',
19+
template:
20+
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
21+
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
22+
}
23+
}))
24+
25+
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
26+
default: {
27+
name: 'DefaultThumbnail',
28+
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
29+
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
30+
}
31+
}))
32+
33+
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
34+
default: {
35+
name: 'HoverDissolveThumbnail',
36+
template:
37+
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
38+
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
39+
}
40+
}))
41+
42+
vi.mock('@vueuse/core', () => ({
43+
useElementHover: () => ref(false)
44+
}))
45+
46+
vi.mock('@/scripts/api', () => ({
47+
api: {
48+
fileURL: (path: string) => `/fileURL${path}`,
49+
apiURL: (path: string) => `/apiURL${path}`
50+
}
51+
}))
52+
53+
describe('TemplateWorkflowCard', () => {
54+
const createTemplate = (overrides = {}): TemplateInfo => ({
55+
name: 'test-template',
56+
mediaType: 'image',
57+
mediaSubtype: 'png',
58+
thumbnailVariant: 'default',
59+
description: 'Test description',
60+
...overrides
61+
})
62+
63+
const mountCard = (props = {}) => {
64+
return mount(TemplateWorkflowCard, {
65+
props: {
66+
sourceModule: 'default',
67+
categoryTitle: 'Test Category',
68+
loading: false,
69+
template: createTemplate(),
70+
...props
71+
},
72+
global: {
73+
stubs: {
74+
Card: {
75+
template:
76+
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
77+
props: ['dataTestid', 'pt']
78+
},
79+
ProgressSpinner: {
80+
template: '<div class="progress-spinner"></div>'
81+
}
82+
}
83+
}
84+
})
85+
}
86+
87+
it('emits loadWorkflow event when clicked', async () => {
88+
const wrapper = mountCard({
89+
template: createTemplate({ name: 'test-workflow' })
90+
})
91+
await wrapper.find('.card').trigger('click')
92+
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
93+
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
94+
})
95+
96+
it('shows loading spinner when loading is true', () => {
97+
const wrapper = mountCard({ loading: true })
98+
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
99+
})
100+
101+
it('renders audio thumbnail for audio media type', () => {
102+
const wrapper = mountCard({
103+
template: createTemplate({ mediaType: 'audio' })
104+
})
105+
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
106+
})
107+
108+
it('renders compare slider thumbnail for compareSlider variant', () => {
109+
const wrapper = mountCard({
110+
template: createTemplate({ thumbnailVariant: 'compareSlider' })
111+
})
112+
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
113+
})
114+
115+
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
116+
const wrapper = mountCard({
117+
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
118+
})
119+
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
120+
})
121+
122+
it('renders default thumbnail by default', () => {
123+
const wrapper = mountCard()
124+
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
125+
})
126+
127+
it('passes correct props to default thumbnail for video', () => {
128+
const wrapper = mountCard({
129+
template: createTemplate({ mediaType: 'video' })
130+
})
131+
const thumbnail = wrapper.find('.mock-default-thumbnail')
132+
expect(thumbnail.exists()).toBe(true)
133+
})
134+
135+
it('uses zoomHover scale when variant is zoomHover', () => {
136+
const wrapper = mountCard({
137+
template: createTemplate({ thumbnailVariant: 'zoomHover' })
138+
})
139+
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
140+
})
141+
142+
it('displays localized title for default source module', () => {
143+
const wrapper = mountCard({
144+
sourceModule: 'default',
145+
template: createTemplate({ localizedTitle: 'My Localized Title' })
146+
})
147+
expect(wrapper.text()).toContain('My Localized Title')
148+
})
149+
150+
it('displays template name as title for non-default source modules', () => {
151+
const wrapper = mountCard({
152+
sourceModule: 'custom',
153+
template: createTemplate({ name: 'custom-template' })
154+
})
155+
expect(wrapper.text()).toContain('custom-template')
156+
})
157+
158+
it('displays localized description for default source module', () => {
159+
const wrapper = mountCard({
160+
sourceModule: 'default',
161+
template: createTemplate({
162+
localizedDescription: 'My Localized Description'
163+
})
164+
})
165+
expect(wrapper.text()).toContain('My Localized Description')
166+
})
167+
168+
it('processes description for non-default source modules', () => {
169+
const wrapper = mountCard({
170+
sourceModule: 'custom',
171+
template: createTemplate({ description: 'custom_module-description' })
172+
})
173+
expect(wrapper.text()).toContain('custom module description')
174+
})
175+
176+
it('generates correct thumbnail URLs for default source module', () => {
177+
const wrapper = mountCard({
178+
sourceModule: 'default',
179+
template: createTemplate({
180+
name: 'my-template',
181+
mediaSubtype: 'jpg'
182+
})
183+
})
184+
const vm = wrapper.vm as any
185+
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
186+
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
187+
})
188+
189+
it('generates correct thumbnail URLs for custom source module', () => {
190+
const wrapper = mountCard({
191+
sourceModule: 'custom-module',
192+
template: createTemplate({
193+
name: 'my-template',
194+
mediaSubtype: 'png'
195+
})
196+
})
197+
const vm = wrapper.vm as any
198+
expect(vm.baseThumbnailSrc).toBe(
199+
'/apiURL/workflow_templates/custom-module/my-template.png'
200+
)
201+
expect(vm.overlayThumbnailSrc).toBe(
202+
'/apiURL/workflow_templates/custom-module/my-template.png'
203+
)
204+
})
205+
})

src/components/templates/TemplateWorkflowCard.vue

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
:overlay-image-src="overlayThumbnailSrc"
2121
:alt="title"
2222
:is-hovered="isHovered"
23+
:is-video="
24+
template.mediaType === 'video' ||
25+
template.mediaSubtype === 'webp'
26+
"
2327
/>
2428
</template>
2529
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
@@ -28,13 +32,21 @@
2832
:overlay-image-src="overlayThumbnailSrc"
2933
:alt="title"
3034
:is-hovered="isHovered"
35+
:is-video="
36+
template.mediaType === 'video' ||
37+
template.mediaSubtype === 'webp'
38+
"
3139
/>
3240
</template>
3341
<template v-else>
3442
<DefaultThumbnail
3543
:src="baseThumbnailSrc"
3644
:alt="title"
3745
:is-hovered="isHovered"
46+
:is-video="
47+
template.mediaType === 'video' ||
48+
template.mediaSubtype === 'webp'
49+
"
3850
:hover-zoom="
3951
template.thumbnailVariant === 'zoomHover'
4052
? UPSCALE_ZOOM_SCALE
@@ -52,18 +64,13 @@
5264
<template #content>
5365
<div class="flex items-center px-4 py-3">
5466
<div class="flex-1 flex flex-col">
55-
<h3 class="line-clamp-2 text-lg font-normal mb-0 h-12" :title="title">
67+
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
5668
{{ title }}
5769
</h3>
5870
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
5971
{{ description }}
6072
</p>
6173
</div>
62-
<div
63-
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full"
64-
>
65-
<i class="pi pi-angle-right text-2xl" />
66-
</div>
6774
</div>
6875
</template>
6976
</Card>

0 commit comments

Comments
 (0)