New Workflow Templates Modal (#5142)

This pull request refactors and simplifies the template workflow card
components and related UI in the codebase. The main changes focus on
removing unused or redundant components, improving visual and
interaction consistency, and enhancing error handling for images. Below
are the most important changes grouped by theme:

**Template Workflow Card Refactor and Cleanup**

* Removed the `TemplateWorkflowCard.vue` component and its associated
test file `TemplateWorkflowCard.spec.ts`, as well as the
`TemplateWorkflowCardSkeleton.vue` and `TemplateWorkflowList.vue`
components, indicating a shift away from the previous card-based
template workflow UI.
[[1]](diffhunk://#diff-49569af0404058e8257f3cc0716b066517ce7397dd58744b02aa0d0c61f2a815L1-L139)
[[2]](diffhunk://#diff-9fa6fc1470371f0b520d4deda4129fb313b1bea69888a376556f4bd824f9d751L1-L263)
[[3]](diffhunk://#diff-bc35b6f77d1cee6e86b05d0da80b7bd40013c7a6a97a89706d3bc52573e1c574L1-L30)
[[4]](diffhunk://#diff-48171f792b22022526fca411d3c3a366d48b675dab77943a20846ae079cbaf3bL1-L68)
* Removed the `TemplateSearchBar.vue` component, suggesting a redesign
or replacement of the search/filter UI for templates.

**UI and Interaction Improvements**

* Improved the `CardBottom.vue` component by making its height
configurable via a `fullHeight` prop, enhancing layout flexibility.
* Updated the `CardContainer.vue` component to add hover effects
(background, border, shadow, and padding) and support a new `none`
aspect ratio for more flexible card layouts.

**Image and Input Enhancements**

* Enhanced the `LazyImage.vue` component to display a default
placeholder image when an image fails to load, improving error handling
and user experience.
* Improved the `SearchBox.vue` component by making the input focusable
when clicking anywhere on the wrapper, and added a template ref for
better accessibility and usability.
[[1]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL2-R5)
[[2]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL16-R17)
[[3]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bR33-R39)

**Minor UI Tweaks**

* Adjusted label styling in `SingleSelect.vue` to remove unnecessary
overflow handling, simplifying the visual layout.

---------

Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-09-26 19:52:19 +01:00
committed by GitHub
parent 49f373c46f
commit d954336973
44 changed files with 1841 additions and 1356 deletions

View File

@@ -1,64 +0,0 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
</div>
</div>
<div class="flex items-center gap-4 mt-2">
<small
v-if="searchQuery && filteredCount !== null"
class="text-color-secondary"
>
{{ $t('g.resultsCount', { count: filteredCount }) }}
</small>
<Button
v-if="searchQuery"
text
size="small"
icon="pi pi-times"
:label="$t('g.clearFilters')"
@click="clearFilters"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const { filteredCount } = defineProps<{
filteredCount?: number | null
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
clearFilters: []
}>()
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
}
</script>

View File

@@ -1,273 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
default: {
name: 'AudioThumbnail',
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
props: ['src']
}
}))
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
default: {
name: 'CompareSliderThumbnail',
template:
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
default: {
name: 'DefaultThumbnail',
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
}
}))
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
default: {
name: 'HoverDissolveThumbnail',
template:
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@vueuse/core', () => ({
useElementHover: () => ref(false)
}))
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
isLoaded: true,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: []
})
})
)
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback: string) => fallback || key
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
getTemplateThumbnailUrl: (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? `/fileURL/templates/${template.name}`
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
const indexSuffix =
sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
},
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
},
getTemplateDescription: (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
},
loadWorkflowTemplate: vi.fn()
})
})
)
describe('TemplateWorkflowCard', () => {
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: 'Test description',
...overrides
})
const mountCard = (props = {}) => {
return mount(TemplateWorkflowCard, {
props: {
sourceModule: 'default',
categoryTitle: 'Test Category',
loading: false,
template: createTemplate(),
...props
},
global: {
stubs: {
Card: {
template:
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
props: ['dataTestid', 'pt']
},
ProgressSpinner: {
template: '<div class="progress-spinner"></div>'
}
}
}
})
}
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountCard({
template: createTemplate({ name: 'test-workflow' })
})
await wrapper.find('.card').trigger('click')
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
})
it('shows loading spinner when loading is true', () => {
const wrapper = mountCard({ loading: true })
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
})
it('renders audio thumbnail for audio media type', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'audio' })
})
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
})
it('renders compare slider thumbnail for compareSlider variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'compareSlider' })
})
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
})
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
})
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
})
it('renders default thumbnail by default', () => {
const wrapper = mountCard()
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('passes correct props to default thumbnail for video', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'video' })
})
const thumbnail = wrapper.find('.mock-default-thumbnail')
expect(thumbnail.exists()).toBe(true)
})
it('uses zoomHover scale when variant is zoomHover', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'zoomHover' })
})
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('displays localized title for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({ localizedTitle: 'My Localized Title' })
})
expect(wrapper.text()).toContain('My Localized Title')
})
it('displays template name as title for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ name: 'custom-template' })
})
expect(wrapper.text()).toContain('custom-template')
})
it('displays localized description for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
localizedDescription: 'My Localized Description'
})
})
expect(wrapper.text()).toContain('My Localized Description')
})
it('processes description for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ description: 'custom_module-description' })
})
expect(wrapper.text()).toContain('custom module description')
})
it('generates correct thumbnail URLs for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'jpg'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
})
it('generates correct thumbnail URLs for custom source module', () => {
const wrapper = mountCard({
sourceModule: 'custom-module',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'png'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
expect(vm.overlayThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
})
})

View File

@@ -1,139 +0,0 @@
<template>
<Card
ref="cardRef"
:data-testid="`template-workflow-${template.name}`"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
@click="$emit('loadWorkflow', template.name)"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="baseThumbnailSrc" />
</template>
<template v-else-if="template.thumbnailVariant === 'compareSlider'">
<CompareSliderThumbnail
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
<HoverDissolveThumbnail
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="baseThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover'
? UPSCALE_ZOOM_SCALE
: DEFAULT_ZOOM_SCALE
"
/>
</template>
<ProgressSpinner
v-if="loading"
class="absolute inset-0 z-1 w-3/12 h-full"
/>
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
{{ title }}
</h3>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
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 { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
const DEFAULT_ZOOM_SCALE = 5
const { sourceModule, loading, template } = defineProps<{
sourceModule: string
categoryTitle: string
loading: boolean
template: TemplateInfo
}>()
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,30 +0,0 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,68 +0,0 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
</span>
</template>
</Column>
<Column field="actions" header="" class="w-12">
<template #body="slotProps">
<Button
icon="pi pi-arrow-right"
text
rounded
size="small"
:loading="loading === slotProps.data.name"
@click="emit('loadWorkflow', slotProps.data.name)"
/>
</template>
</Column>
</DataTable>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { sourceModule, loading, templates } = defineProps<{
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const selectedTemplate = ref(null)
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
const enrichedTemplates = computed(() => {
return templates.map((template) => {
const actualSourceModule = template.sourceModule || sourceModule
return {
...template,
title: getTemplateTitle(template, actualSourceModule),
description: getTemplateDescription(template, actualSourceModule)
}
})
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,185 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
vi.mock('primevue/dataview', () => ({
default: {
name: 'DataView',
template: `
<div class="p-dataview">
<div class="dataview-header"><slot name="header"></slot></div>
<div class="dataview-content">
<slot name="grid" :items="value"></slot>
</div>
</div>
`,
props: ['value', 'layout', 'lazy', 'pt']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template:
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
props: ['modelValue', 'options', 'allowEmpty']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
<div
class="mock-template-card"
:data-name="template.name"
:data-source-module="sourceModule"
:data-category-title="categoryTitle"
:data-loading="loading"
@click="$emit('loadWorkflow', template.name)"
></div>
`,
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
emits: ['loadWorkflow']
}
}))
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
default: {
template: '<div class="mock-template-list"></div>',
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
emits: ['loadWorkflow']
}
}))
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: ['searchQuery', 'filteredCount'],
emits: ['update:searchQuery', 'clearFilters']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 }
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: `Description for ${name}`
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
sourceModule: 'default',
categoryTitle: 'Test Category',
templates: [
createTemplate('template-1'),
createTemplate('template-2'),
createTemplate('template-3')
],
loading: null,
...props
},
global: {
plugins: [i18n]
}
})
}
it('renders template cards for each template', () => {
const wrapper = mountView()
const cards = wrapper.findAll('.mock-template-card')
expect(cards.length).toBe(3)
expect(cards[0].attributes('data-name')).toBe('template-1')
expect(cards[1].attributes('data-name')).toBe('template-2')
expect(cards[2].attributes('data-name')).toBe('template-3')
})
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountView()
const card = wrapper.find('.mock-template-card')
await card.trigger('click')
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
// Check that the emitted event contains the template name
const emitted = wrapper.emitted('loadWorkflow')
expect(emitted).toBeTruthy()
expect(emitted?.[0][0]).toBe('template-1')
})
it('passes correct props to template cards', () => {
const wrapper = mountView({
sourceModule: 'custom',
categoryTitle: 'Custom Category'
})
const card = wrapper.find('.mock-template-card')
expect(card.exists()).toBe(true)
expect(card.attributes('data-source-module')).toBe('custom')
expect(card.attributes('data-category-title')).toBe('Custom Category')
})
it('applies loading state correctly to cards', () => {
const wrapper = mountView({
loading: 'template-2'
})
const cards = wrapper.findAll('.mock-template-card')
// Only the second card should have loading=true since loading="template-2"
expect(cards[0].attributes('data-loading')).toBe('false')
expect(cards[1].attributes('data-loading')).toBe('true')
expect(cards[2].attributes('data-loading')).toBe('false')
})
})

View File

@@ -1,168 +0,0 @@
<template>
<DataView
:value="displayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<TemplateSearchBar
v-model:search-query="searchQuery"
:filtered-count="filteredCount"
@clear-filters="() => reset()"
/>
</div>
</template>
<template #list="{ items }">
<TemplateWorkflowList
:source-module="sourceModule"
:templates="items"
:loading="loading"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
</template>
<template #grid="{ items }">
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
</div>
</template>
</DataView>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { t } = useI18n()
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const layout = useLocalStorage<'grid' | 'list'>(
'Comfy.TemplateWorkflow.Layout',
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
const templatesRef = computed(() => templates || [])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templatesRef)
// When searching, show all results immediately without pagination
// When not searching, use lazy pagination
const shouldUsePagination = computed(() => !searchQuery.value.trim())
// Lazy pagination setup using filtered templates
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset
} = useLazyPagination(filteredTemplates, {
itemsPerPage: 12
})
// Final templates to display
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates, searchQuery], () => {
reset()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const onLoadWorkflow = (name: string) => {
emit('loadWorkflow', name)
}
</script>

View File

@@ -1,112 +0,0 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
data-testid="template-workflows-content"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<Divider
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
/>
<div class="flex flex-1 relative overflow-hidden">
<aside
v-if="isSideNavOpen"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
}
</script>

View File

@@ -1,7 +0,0 @@
<template>
<div>
<h3 class="px-4">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>
</template>

View File

@@ -1,50 +0,0 @@
<template>
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
:options="tabs"
option-group-label="label"
option-label="localizedTitle"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
list-style="max-height:unset"
@update:model-value="handleTabSelection"
>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
</div>
</template>
</Listbox>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import type {
TemplateGroup,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
defineProps<{
tabs: TemplateGroup[]
selectedTab: WorkflowTemplates | null
}>()
const emit = defineEmits<{
(e: 'update:selectedTab', tab: WorkflowTemplates): void
}>()
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -1,6 +1,12 @@
<template>
<BaseThumbnail>
<div class="w-full h-full flex items-center justify-center p-4">
<div
class="w-full h-full flex items-center justify-center p-4"
:style="{
backgroundImage: 'url(/assets/images/default-template.png)',
backgroundRepeat: 'round'
}"
>
<audio controls class="w-full relative" :src="src" @click.stop />
</div>
</BaseThumbnail>

View File

@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
vm.error = true
await nextTick()
expect(wrapper.find('.pi-file').exists()).toBe(true)
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
expect(
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
).toBe(true)
})
it('applies transition classes to content', () => {

View File

@@ -1,5 +1,7 @@
<template>
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
<div
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
>
<div
v-if="!error"
ref="contentRef"
@@ -11,7 +13,11 @@
<slot />
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<i class="pi pi-file text-4xl" />
<img
src="/assets/images/default-template.png"
draggable="false"
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
/>
</div>
</div>
</template>