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

@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
index = '1'
) => {
const basePath =
sourceModule === 'default'
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
const getTemplateDescription = (template: TemplateInfo) => {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}
/**

View File

@@ -1,23 +1,28 @@
import { groupBy } from 'es-toolkit/compat'
import Fuse from 'fuse.js'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { st } from '@/i18n'
import { i18n, st } from '@/i18n'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { getCategoryIcon } from '@/utils/categoryIcons'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { normalizeI18nKey } from '@/utils/formatUtil'
} from '../types/template'
const SHOULD_SORT_CATEGORIES = new Set([
// API Node templates should be strictly sorted by name to avoid any
// favoritism or bias towards a particular API. Other categories can
// have their ordering specified in index.json freely.
'Image API',
'Video API'
])
// Enhanced template interface for easier filtering
interface EnhancedTemplate extends TemplateInfo {
sourceModule: string
category?: string
categoryType?: string
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
isEssential?: boolean
searchableText?: string
}
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
/**
* Sort a list of templates in alphabetical order by localized display name.
*/
const sortTemplateList = (templates: TemplateInfo[]) =>
templates.sort((a, b) => {
const aName = st(
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
a.title ?? a.name
)
const bName = st(
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
b.name
)
return aName.localeCompare(bName)
})
// Store filter mappings for dynamic categories
type FilterData = {
category?: string
categoryGroup?: string
}
/**
* Sort any template categories (grouped templates) that should be sorted.
* Leave other categories' templates in their original order specified in index.json
*/
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
categories.map((category) => {
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
return {
...category,
templates: sortTemplateList(category.templates)
}
}
return category
})
const categoryFilters = ref(new Map<string, FilterData>())
/**
* Add localization fields to a template.
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
/**
* Original grouped templates for backward compatibility
*/
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
),
...coreTemplates.value.map(localizeTemplateCategory),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
@@ -169,38 +152,286 @@ export const useWorkflowTemplatesStore = defineStore(
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
const groupedByCategory = [
{
label: st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
),
modules: [
createAllCategory(),
...allTemplates.filter((t) => t.moduleName === 'default')
]
},
...(Object.keys(customTemplates.value).length > 0
? [
{
label: st(
'templateWorkflows.category.Custom Nodes',
'Custom Nodes'
),
modules: allTemplates.filter((t) => t.moduleName !== 'default')
}
]
: [])
]
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
return groupedByCategory
})
/**
* Enhanced templates with proper categorization for filtering
*/
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
const allTemplates: EnhancedTemplate[] = []
// Process core templates
coreTemplates.value.forEach((category) => {
category.templates.forEach((template) => {
const enhancedTemplate: EnhancedTemplate = {
...template,
sourceModule: category.moduleName,
category: category.title,
categoryType: category.type,
categoryGroup: category.category,
isEssential: category.isEssential,
searchableText: [
template.title || template.name,
template.description || '',
category.title,
...(template.tags || []),
...(template.models || [])
].join(' ')
}
allTemplates.push(enhancedTemplate)
})
})
// Process custom templates
Object.entries(customTemplates.value).forEach(
([moduleName, templates]) => {
templates.forEach((name) => {
const enhancedTemplate: EnhancedTemplate = {
name,
title: name,
description: name,
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: moduleName,
category: 'Extensions',
categoryType: 'extension',
searchableText: `${name} ${moduleName} extension`
}
allTemplates.push(enhancedTemplate)
})
}
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
return allTemplates
})
/**
* Fuse.js instance for advanced template searching and filtering
*/
const templateFuse = computed(() => {
const fuseOptions = {
keys: [
{ name: 'searchableText', weight: 0.4 },
{ name: 'title', weight: 0.3 },
{ name: 'name', weight: 0.2 },
{ name: 'tags', weight: 0.1 }
],
threshold: 0.3,
includeScore: true
}
return new Fuse(enhancedTemplates.value, fuseOptions)
})
/**
* Filter templates by category ID using stored filter mappings
*/
const filterTemplatesByCategory = (categoryId: string) => {
if (categoryId === 'all') {
return enhancedTemplates.value
}
if (categoryId === 'basics') {
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter((t) => t.isEssential)
}
// Handle extension-specific filters
if (categoryId.startsWith('extension-')) {
const moduleName = categoryId.replace('extension-', '')
return enhancedTemplates.value.filter(
(t) => t.sourceModule === moduleName
)
}
return groupedByCategory
// Look up the filter from our stored mappings
const filter = categoryFilters.value.get(categoryId)
if (!filter) {
return enhancedTemplates.value
}
// Apply the filter
return enhancedTemplates.value.filter((template) => {
if (filter.category && template.category !== filter.category) {
return false
}
if (
filter.categoryGroup &&
template.categoryGroup !== filter.categoryGroup
) {
return false
}
return true
})
}
/**
* New navigation structure dynamically built from JSON categories
*/
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
if (!isLoaded.value) return []
const items: (NavItemData | NavGroupData)[] = []
// Clear and rebuild filter mappings
categoryFilters.value.clear()
// 1. All Templates - always first
items.push({
id: 'all',
label: st('templateWorkflows.category.All', 'All Templates'),
icon: getCategoryIcon('all')
})
// 2. Basics (isEssential categories) - always second if it exists
let gettingStartedText = 'Getting Started'
const essentialCat = coreTemplates.value.find(
(cat) => cat.isEssential && cat.templates.length > 0
)
const hasEssentialCategories = Boolean(essentialCat)
if (essentialCat) {
gettingStartedText = essentialCat.title
}
if (hasEssentialCategories) {
items.push({
id: 'basics',
label: gettingStartedText,
icon: 'icon-[lucide--graduation-cap]'
})
}
// 3. Group categories from JSON dynamically
const categoryGroups = new Map<
string,
{ title: string; items: NavItemData[] }
>()
// Process all categories from JSON
coreTemplates.value.forEach((category) => {
// Skip essential categories as they're handled as Basics
if (category.isEssential) return
const categoryGroup = category.category
const categoryIcon = category.icon
if (categoryGroup) {
if (!categoryGroups.has(categoryGroup)) {
categoryGroups.set(categoryGroup, {
title: categoryGroup,
items: []
})
}
const group = categoryGroups.get(categoryGroup)!
// Generate unique ID for this category
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
// Store the filter mapping
categoryFilters.value.set(categoryId, {
category: category.title,
categoryGroup: categoryGroup
})
group.items.push({
id: categoryId,
label: st(
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
category.title
),
icon: categoryIcon || getCategoryIcon(category.type || 'default')
})
}
})
// Add grouped categories
categoryGroups.forEach((group, groupName) => {
if (group.items.length > 0) {
items.push({
title: st(
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
groupName
.split(' ')
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ')
),
items: group.items
})
}
})
// 4. Extensions - always last
const extensionCounts = enhancedTemplates.value.filter(
(t) => t.sourceModule !== 'default'
).length
if (extensionCounts > 0) {
// Get unique extension modules
const extensionModules = Array.from(
new Set(
enhancedTemplates.value
.filter((t) => t.sourceModule !== 'default')
.map((t) => t.sourceModule)
)
).sort()
const extensionItems: NavItemData[] = extensionModules.map(
(moduleName) => ({
id: `extension-${moduleName}`,
label: st(
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
moduleName
),
icon: getCategoryIcon('extensions')
})
)
items.push({
title: st('templateWorkflows.category.Extensions', 'Extensions'),
items: extensionItems,
collapsible: true
})
}
return items
})
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
coreTemplates.value = await api.getCoreWorkflowTemplates()
const locale = i18n.global.locale.value
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
isLoaded.value = true
}
} catch (error) {
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
return {
groupedTemplates,
navGroupedTemplates,
enhancedTemplates,
templateFuse,
filterTemplatesByCategory,
isLoaded,
loadWorkflowTemplates
}

View File

@@ -11,13 +11,25 @@ export interface TemplateInfo {
description: string
localizedTitle?: string
localizedDescription?: string
isEssential?: boolean
sourceModule?: string
tags?: string[]
models?: string[]
date?: string
useCase?: string
license?: string
size?: number
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
localizedTitle?: string
category?: string
type?: string
icon?: string
isEssential?: boolean
}
export interface TemplateGroup {