mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 07:00:23 +00:00
refactor: delete unused components
This commit is contained in:
@@ -1,263 +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 { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
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('@/stores/workflowTemplatesStore', () => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
<template>
|
||||
<Card
|
||||
ref="cardRef"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="w-full template-card rounded-2xl overflow-hidden cursor-pointer h-full"
|
||||
:pt="{
|
||||
root: { class: 'bg-transparent shadow-none' },
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="w-full">
|
||||
<div class="relative w-full overflow-hidden rounded-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
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="template.tags && template.tags.length > 0"
|
||||
class="absolute inset-0 z-10 pointer-events-none"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<div
|
||||
class="flex flex-wrap absolute bottom-4 left-4 px-1 pointer-events-auto gap-2"
|
||||
>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-1 w-3/12 h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col px-4 py-3 flex-1">
|
||||
<div class="flex-1">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-1" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="flex justify-between gap-2">
|
||||
<p
|
||||
class="line-clamp-2 text-sm text-muted mb-3"
|
||||
:title="description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<div
|
||||
v-if="template.tutorialUrl"
|
||||
class="flex flex-col-reverse justify-center"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center rounded-lg bg-[#FDFBFA] w-8 h-8 cursor-pointer transition-opacity duration-200',
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
]"
|
||||
@click="openTutorial"
|
||||
>
|
||||
<i class="icon-[comfy--dark-info] w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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 SquareChip from '@/components/chip/SquareChip.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 '@/composables/useTemplateWorkflows'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
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))
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
const openTutorial = (event: Event) => {
|
||||
event.stopPropagation()
|
||||
if (template.tutorialUrl) {
|
||||
window.open(template.tutorialUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-full 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="w-full">
|
||||
<div class="relative w-full aspect-square overflow-hidden rounded-t-lg">
|
||||
<Skeleton class="w-full h-full" />
|
||||
</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>
|
||||
@@ -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 '@/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -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 { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,143 +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 #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 { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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 '@/types/workflowTemplateTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { 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 } = 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>
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full w-full relative pb-6 overflow-x-hidden"
|
||||
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="h-full"
|
||||
: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 '@/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="px-4">
|
||||
<span>{{ $t('templateWorkflows.title') }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
tabs: TemplateGroup[]
|
||||
selectedTab: WorkflowTemplates | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedTab', tab: WorkflowTemplates): void
|
||||
}>()
|
||||
|
||||
const handleTabSelection = (tab: WorkflowTemplates) => {
|
||||
emit('update:selectedTab', tab)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user