WIP Template filters

This commit is contained in:
Johnpaul
2025-08-07 00:46:12 +01:00
parent 6316dde209
commit 7840b1c05c
13 changed files with 840 additions and 223 deletions

View File

@@ -2,43 +2,123 @@
<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'
<div class="relative w-full">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10"
>
<i class="pi pi-search text-surface-400"></i>
</div>
<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-md pl-10 pr-10'
}
},
loader: {
style: 'display: none'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
<div
v-if="searchQuery"
class="absolute inset-y-0 right-0 flex items-center pr-3 z-10"
>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer"
@click="searchQuery = ''"
>
<i class="pi pi-times"></i>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Model Filter Dropdown -->
<div class="relative">
<Button
ref="modelFilterButton"
:label="modelFilterLabel"
icon="pi pi-filter"
outlined
class="rounded-2xl"
@click="toggleModelFilter"
/>
<Popover
ref="modelFilterPopover"
:pt="{
root: { class: 'w-64 max-h-80 overflow-auto' }
}"
>
<div class="p-3">
<div class="font-medium mb-2">
{{ $t('templateWorkflows.modelFilter') }}
</div>
<div class="flex flex-col gap-2">
<div
v-for="model in availableModels"
:key="model"
class="flex items-center"
>
<Checkbox
:model-value="selectedModels.includes(model)"
:binary="true"
@change="toggleModel(model)"
/>
<label class="ml-2 text-sm">{{ model }}</label>
</div>
</div>
</div>
</Popover>
</div>
<!-- Sort Dropdown -->
<Select
v-model="sortBy"
:options="sortOptions"
option-label="label"
option-value="value"
:placeholder="$t('templateWorkflows.sort')"
class="rounded-2xl"
:pt="{
root: { class: 'min-w-32' }
}"
: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"
/>
<!-- Filter Tags Row -->
<div
v-if="selectedModels.length > 0"
class="flex items-center gap-2 mt-3 overflow-x-auto pb-2"
>
<div class="flex gap-2">
<Tag
v-for="model in selectedModels"
:key="model"
:value="model"
severity="secondary"
>
<template #icon>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer ml-1"
@click="removeModel(model)"
>
<i class="pi pi-times text-xs"></i>
</button>
</template>
</Tag>
</div>
</div>
</div>
</template>
@@ -46,19 +126,70 @@
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Popover from 'primevue/popover'
import Select from 'primevue/select'
import Tag from 'primevue/tag'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { filteredCount } = defineProps<{
import type { SortOption } from '@/composables/useTemplateFiltering'
const { t } = useI18n()
const { availableModels } = defineProps<{
filteredCount?: number | null
availableModels: string[]
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const selectedModels = defineModel<string[]>('selectedModels', {
default: () => []
})
const sortBy = defineModel<SortOption>('sortBy', { default: 'recommended' })
const emit = defineEmits<{
clearFilters: []
}>()
// emit removed - no longer needed since clearFilters was removed
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
const modelFilterButton = ref<HTMLElement>()
const modelFilterPopover = ref()
const sortOptions = [
{ label: t('templateWorkflows.sortRecommended'), value: 'recommended' },
{ label: t('templateWorkflows.sortAlphabetical'), value: 'alphabetical' },
{ label: t('templateWorkflows.sortNewest'), value: 'newest' }
]
const modelFilterLabel = computed(() => {
if (selectedModels.value.length === 0) {
return t('templateWorkflows.modelFilter')
} else if (selectedModels.value.length === 1) {
return selectedModels.value[0]
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModels.value.length
})
}
})
const toggleModelFilter = (event: Event) => {
modelFilterPopover.value.toggle(event)
}
const toggleModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
} else {
selectedModels.value.push(model)
}
}
const removeModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
}
}
// clearFilters function removed - no longer used since we removed the clear button
</script>

View File

@@ -62,14 +62,49 @@
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex items-center px-4 py-3 relative">
<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>
<!-- Description / Action Buttons Container -->
<div class="relative grow">
<!-- Description Text -->
<p
class="line-clamp-2 text-sm text-muted grow transition-opacity duration-200"
:class="{ 'opacity-0': isHovered }"
:title="description"
>
{{ description }}
</p>
<!-- Action Buttons (visible on hover) -->
<div
v-if="isHovered"
class="absolute inset-0 flex items-center gap-2 transition-opacity duration-200"
>
<Button
v-if="template.tutorialUrl"
size="small"
severity="secondary"
outlined
class="flex-1 rounded-lg"
@click.stop="openTutorial"
>
{{ $t('templateWorkflows.tutorial') }}
</Button>
<Button
size="small"
severity="primary"
:class="template.tutorialUrl ? 'flex-1' : 'w-full'"
class="rounded-lg"
@click.stop="$emit('loadWorkflow', template.name)"
>
{{ $t('templateWorkflows.useTemplate') }}
</Button>
</div>
</div>
</div>
</div>
</template>
@@ -78,6 +113,7 @@
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Button from 'primevue/button'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
@@ -133,6 +169,12 @@ const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const openTutorial = () => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank', 'noopener,noreferrer')
}
}
defineEmits<{
loadWorkflow: [name: string]
}>()

View File

@@ -29,6 +29,15 @@ vi.mock('primevue/selectbutton', () => ({
}
}))
vi.mock('primevue/button', () => ({
default: {
name: 'Button',
template: '<button class="p-button"><slot></slot></button>',
props: ['severity', 'outlined', 'size', 'class'],
emits: ['click']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
@@ -57,8 +66,19 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: ['searchQuery', 'filteredCount'],
emits: ['update:searchQuery', 'clearFilters']
props: [
'searchQuery',
'filteredCount',
'availableModels',
'selectedModels',
'sortBy'
],
emits: [
'update:searchQuery',
'update:selectedModels',
'update:sortBy',
'clearFilters'
]
}
}))
@@ -89,8 +109,14 @@ vi.mock('@/composables/useLazyPagination', () => ({
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
selectedModels: { value: [] },
selectedSubcategory: { value: null },
sortBy: { value: 'recommended' },
availableSubcategories: { value: [] },
availableModels: { value: [] },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 }
filteredCount: { value: templates.value?.length || 0 },
resetFilters: vi.fn()
})
}))
@@ -127,6 +153,8 @@ describe('TemplateWorkflowView', () => {
createTemplate('template-3')
],
loading: null,
availableSubcategories: [],
selectedSubcategory: null,
...props
},
global: {

View File

@@ -1,6 +1,6 @@
<template>
<DataView
:value="displayTemplates"
:value="finalDisplayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
@@ -21,11 +21,37 @@
</template>
</SelectButton>
</div>
<TemplateSearchBar
v-model:search-query="searchQuery"
:filtered-count="filteredCount"
@clear-filters="() => reset()"
/>
<!-- Subcategory Navigation -->
<div
v-if="availableSubcategories.length > 0"
class="mb-4 overflow-x-scroll flex max-w-[1000px]"
>
<div class="flex gap-2 pb-2">
<Button
:severity="selectedSubcategory === null ? 'primary' : 'secondary'"
:outlined="selectedSubcategory !== null"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', null)"
>
{{ $t('templateWorkflows.allSubcategories') }}
</Button>
<Button
v-for="subcategory in availableSubcategories"
:key="subcategory"
:severity="
selectedSubcategory === subcategory ? 'primary' : 'secondary'
"
:outlined="selectedSubcategory !== subcategory"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', subcategory)"
>
{{ subcategory }}
</Button>
</div>
</div>
</div>
</template>
@@ -76,28 +102,37 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
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 '@/types/workflowTemplateTypes'
const { t } = useI18n()
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
const {
title,
sourceModule,
categoryTitle,
loading,
templates,
availableSubcategories,
selectedSubcategory
} = defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
availableSubcategories: string[]
selectedSubcategory: string | null
}>()
const layout = useLocalStorage<'grid' | 'list'>(
@@ -108,31 +143,28 @@ const layout = useLocalStorage<'grid' | 'list'>(
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
const templatesRef = computed(() => templates || [])
// Since filtering is now handled at parent level, we just use the templates directly
const displayTemplates = computed(() => templates || [])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templatesRef)
// Simplified pagination - only show pagination when we have lots of templates
const shouldUsePagination = computed(() => templates.length > 12)
// 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
// Lazy pagination setup using templates directly
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset
} = useLazyPagination(filteredTemplates, {
reset: resetPagination
} = useLazyPagination(displayTemplates, {
itemsPerPage: 12
})
// Final templates to display
const displayTemplates = computed(() => {
// Final templates to display with pagination
const finalDisplayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
: displayTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
@@ -154,12 +186,13 @@ useIntersectionObserver(
}
)
watch([() => templates, searchQuery], () => {
reset()
watch([() => templates], () => {
resetPagination()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
'update:selectedSubcategory': [value: string | null]
}>()
const onLoadWorkflow = (name: string) => {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
class="flex flex-col h-[83vh] w-[90vw] relative pb-6 px-8 mx-auto"
data-testid="template-workflows-content"
>
<Button
@@ -12,12 +12,13 @@
@click="toggleSideNav"
/>
<Divider
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
class="my-0 w-[90vw] -mx-8 relative [&::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"
class="absolute translate-x-0 top-0 left-0 h-full w-60 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
@@ -25,27 +26,64 @@
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
:selected-subcategory="selectedSubcategory"
:selected-view="selectedView"
@update:selected-subcategory="handleSubcategory"
@update:selected-view="handleViewSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-60': 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
v-if="
isReady &&
(selectedView === 'all' ||
selectedView === 'recent' ||
selectedSubcategory)
"
class="flex flex-col h-full"
>
<div class="px-8 sm:px-12 py-4 border-b border-surface-border/20">
<TemplateSearchBar
v-model:search-query="searchQuery"
v-model:selected-models="selectedModels"
v-model:sort-by="sortBy"
:filtered-count="filteredCount"
:available-models="availableModels"
@clear-filters="resetFilters"
/>
</div>
<TemplateWorkflowView
class="px-8 sm:px-12 flex-1"
:title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
:source-module="selectedTemplate?.moduleName || 'all'"
:templates="filteredTemplates"
:available-subcategories="availableSubcategories"
:selected-subcategory="filterSubcategory"
:loading="loadingTemplateId"
:category-title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
@load-workflow="handleLoadWorkflow"
@update:selected-subcategory="filterSubcategory = $event"
/>
</div>
</div>
</div>
</div>
@@ -56,13 +94,15 @@ 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 { computed, ref, watch } from 'vue'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
import type { TemplateSubcategory } from '@/types/workflowTemplateTypes'
const {
isSmallScreen,
@@ -76,34 +116,78 @@ const {
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
// State for subcategory selection
const selectedSubcategory = ref<TemplateSubcategory | null>(null)
// State for view selection (all vs recent)
const selectedView = ref<'all' | 'recent'>('all')
// Template filtering for the top-level search
const templatesRef = computed(() => {
// If a subcategory is selected, use all templates from that subcategory
if (selectedSubcategory.value) {
return selectedSubcategory.value.modules.flatMap(
(module) => module.templates
)
}
// If "All Templates" view is selected and no subcategory, show all templates across all groups
if (selectedView.value === 'all') {
return allTemplateGroups.value.flatMap((group) =>
group.subcategories.flatMap((subcategory) =>
subcategory.modules.flatMap((module) => module.templates)
)
)
}
// Otherwise, use the selected template's templates (for recent view or fallback)
return selectedTemplate.value?.templates || []
})
const {
searchQuery,
selectedModels,
selectedSubcategory: filterSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters
} = useTemplateFiltering(templatesRef)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
// Start with "All Templates" view by default instead of selecting first subcategory
selectedView.value = 'all'
selectedSubcategory.value = null
}
},
{ once: true }
)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
const handleSubcategory = (subcategory: TemplateSubcategory) => {
selectedSubcategory.value = subcategory
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
// On small screens, close the sidebar when a subcategory is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
const handleViewSelection = (view: 'all' | 'recent') => {
selectedView.value = view
// Clear subcategory selection when switching to header views
selectedSubcategory.value = null
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h3 class="px-4">
<h3 class="px-8 text-base font-medium">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>

View File

@@ -1,50 +1,142 @@
<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"
<ScrollPanel class="w-60" style="height: calc(83vh - 48px)">
<!-- View Selection Header -->
<div
class="text-left py-3 border-b border-surface-200 dark-theme:border-surface-700"
>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
<Listbox
:model-value="selectedView"
:options="viewOptions"
option-label="label"
option-value="value"
:multiple="false"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
header: { class: 'px-0' },
option: {
class: 'px-0 py-2 text-sm font-medium flex items-center gap-1'
}
}"
@update:model-value="handleViewSelection"
>
<template #option="slotProps">
<div class="flex self-center items-center gap-1 py-1 px-4">
<i :class="slotProps.option.icon"></i>
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
<!-- Template Groups and Subcategories -->
<div class="w-full">
<div v-for="group in props.tabs" :key="group.label" class="mb-2">
<!-- Group Header -->
<div class="text-left py-1">
<h3
class="text-muted text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 px-4"
>
{{ group.label }}
</h3>
</div>
</template>
</Listbox>
<!-- Subcategories as Listbox -->
<Listbox
:model-value="selectedSubcategory"
:options="group.subcategories"
option-label="label"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-4 py-3 text-sm' }
}"
@update:model-value="handleSubcategorySelection"
>
<template #option="slotProps">
<div class="flex items-center">
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
</div>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
TemplateGroup,
WorkflowTemplates
TemplateSubcategory
} from '@/types/workflowTemplateTypes'
defineProps<{
const { t } = useI18n()
const props = defineProps<{
tabs: TemplateGroup[]
selectedTab: WorkflowTemplates | null
selectedSubcategory: TemplateSubcategory | null
selectedView: 'all' | 'recent' | null
}>()
const emit = defineEmits<{
(e: 'update:selectedTab', tab: WorkflowTemplates): void
(e: 'update:selectedSubcategory', subcategory: TemplateSubcategory): void
(e: 'update:selectedView', view: 'all' | 'recent'): void
}>()
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
const viewOptions = computed(() => {
// Create a comprehensive "All Templates" subcategory that aggregates all modules
const allTemplatesSubcategory = {
label: t('templateWorkflows.view.allTemplates', 'All Templates'),
modules: props.tabs.flatMap((group: TemplateGroup) =>
group.subcategories.flatMap(
(subcategory: TemplateSubcategory) => subcategory.modules
)
)
}
const recentItemsSubcategory = {
label: t('templateWorkflows.view.recentItems', 'Recent Items'),
modules: [] // Could be populated with recent templates if needed
}
return [
{
...allTemplatesSubcategory,
icon: 'pi pi-list mr-2'
},
{
...recentItemsSubcategory,
icon: 'pi pi-clock mr-2'
}
]
})
const handleSubcategorySelection = (subcategory: TemplateSubcategory) => {
emit('update:selectedSubcategory', subcategory)
}
const handleViewSelection = (subcategory: TemplateSubcategory | null) => {
// Prevent deselection - always keep a view selected
if (subcategory !== null) {
// Emit as subcategory selection since these now have comprehensive module data
emit('update:selectedSubcategory', subcategory)
// Also emit view selection for backward compatibility
const viewValue = subcategory.label.includes('All Templates')
? 'all'
: 'recent'
emit('update:selectedView', viewValue)
}
// If subcategory is null, we don't emit anything, keeping the current selection
}
</script>
<style scoped>
:deep(.p-listbox-header) {
padding: 0;
}
</style>

View File

@@ -2,55 +2,133 @@ import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
export type SortOption = 'recommended' | 'alphabetical' | 'newest'
export interface TemplateFilterOptions {
searchQuery?: string
selectedModels?: string[]
selectedSubcategory?: string | null
sortBy?: SortOption
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const selectedModels = ref<string[]>([])
const selectedSubcategory = ref<string | null>(null)
const sortBy = ref<SortOption>('recommended')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
// Get unique subcategories (tags) from current templates
const availableSubcategories = computed(() => {
const subcategorySet = new Set<string>()
templatesArray.value.forEach((template) => {
template.tags?.forEach((tag) => subcategorySet.add(tag))
})
return Array.from(subcategorySet).sort()
})
// Get unique models from all current templates (don't filter by subcategory for model list)
const availableModels = computed(() => {
const modelSet = new Set<string>()
templatesArray.value.forEach((template) => {
template.models?.forEach((model) => modelSet.add(model))
})
return Array.from(modelSet).sort()
})
const filteredTemplates = computed(() => {
const templateData = templatesArray.value
let templateData = templatesArray.value
if (templateData.length === 0) {
return []
}
if (!searchQuery.value.trim()) {
return templateData
// Filter by search query
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
templateData = templateData.filter((template) => {
const searchableText = [
template.name,
template.title,
template.description,
template.sourceModule,
...(template.tags || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
}
const query = searchQuery.value.toLowerCase().trim()
return templateData.filter((template) => {
const searchableText = [
template.name,
template.description,
template.sourceModule
]
.filter(Boolean)
.join(' ')
.toLowerCase()
// Filter by subcategory
if (selectedSubcategory.value) {
templateData = templateData.filter((template) =>
template.tags?.includes(selectedSubcategory.value!)
)
}
return searchableText.includes(query)
})
// Filter by selected models
if (selectedModels.value.length > 0) {
templateData = templateData.filter((template) =>
template.models?.some((model) => selectedModels.value.includes(model))
)
}
// Sort templates
const sortedData = [...templateData]
switch (sortBy.value) {
case 'alphabetical':
sortedData.sort((a, b) =>
(a.title || a.name).localeCompare(b.title || b.name)
)
break
case 'newest':
sortedData.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
break
case 'recommended':
default:
// Keep original order for recommended (assumes templates are already in recommended order)
break
}
return sortedData
})
const resetFilters = () => {
searchQuery.value = ''
selectedModels.value = []
selectedSubcategory.value = null
sortBy.value = 'recommended'
}
const resetModelFilters = () => {
selectedModels.value = []
}
const filteredCount = computed(() => filteredTemplates.value.length)
return {
searchQuery,
selectedModels,
selectedSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters
resetFilters,
resetModelFilters
}
}

View File

@@ -41,8 +41,14 @@ export function useTemplateWorkflows() {
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
const firstGroup = allTemplateGroups.value[0]
if (
firstGroup.subcategories.length > 0 &&
firstGroup.subcategories[0].modules.length > 0
) {
const firstCategory = firstGroup.subcategories[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
}
@@ -63,9 +69,9 @@ export function useTemplateWorkflows() {
index = ''
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
// sourceModule === 'default'
api.fileURL(`/templates/${template.name}-1`)
// : api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
@@ -106,16 +112,21 @@ export function useTemplateWorkflows() {
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
// Find "All" category in the USE CASES group
const useCasesGroup = allTemplateGroups.value.find(
(g) => g.label === t('templateWorkflows.group.useCases', 'USE CASES')
)
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
const allSubcategory = useCasesGroup?.subcategories.find(
(s) =>
s.label ===
t('templateWorkflows.subcategory.allTemplates', 'All Templates')
)
const allCategory = allSubcategory?.modules.find(
(m: WorkflowTemplates) => m.moduleName === 'all'
)
const template = allCategory?.templates.find(
(t: TemplateInfo) => t.name === id
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false

View File

@@ -552,6 +552,15 @@
"title": "Get Started with a Template",
"loadingMore": "Loading more templates...",
"searchPlaceholder": "Search templates...",
"modelFilter": "Model",
"sort": "Sort",
"sortRecommended": "Recommended",
"sortAlphabetical": "Alphabetical",
"sortNewest": "Newest",
"modelsSelected": "{count} models",
"allSubcategories": "All",
"tutorial": "Tutorial",
"useTemplate": "Use Template",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
@@ -569,6 +578,26 @@
"LLM API": "LLM API",
"All": "All Templates"
},
"group": {
"useCases": "USE CASES",
"toolsBuilding": "TOOLS & BUILDING",
"customCommunity": "CUSTOM & COMMUNITY"
},
"subcategory": {
"allTemplates": "All Templates",
"imageCreation": "Image Creation",
"videoAnimation": "Video & Animation",
"spatial": "3D & Spatial",
"audio": "Audio",
"advancedApis": "Advanced APIs",
"postProcessing": "Post-Processing & Utilities",
"customNodes": "Custom Nodes",
"communityPicks": "Community Picks"
},
"view": {
"allTemplates": "All Templates",
"recentItems": "Recent Items"
},
"templateDescription": {
"Basics": {
"default": "Generate images from text prompts.",

View File

@@ -1,4 +1,3 @@
import { groupBy } from 'lodash'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
@@ -101,49 +100,6 @@ export const useWorkflowTemplatesStore = defineStore(
)
})
// Create an "All" category that combines all templates
const createAllCategory = () => {
// First, get core templates with source module added
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
(category) =>
// For each template in each category, add the sourceModule and pass through any localized fields
category.templates.map((template) => {
// Get localized template with its original category title for i18n lookup
const localizedTemplate = addLocalizedFieldsToTemplate(
template,
category.title
)
return {
...localizedTemplate,
sourceModule: category.moduleName
}
})
)
// Now handle custom templates
const customTemplatesWithSourceModule = Object.entries(
customTemplates.value
).flatMap(([moduleName, templates]) =>
templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name,
sourceModule: moduleName
}))
)
return {
moduleName: 'all',
title: 'All',
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
templates: [
...coreTemplatesWithSourceModule,
...customTemplatesWithSourceModule
]
}
}
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
@@ -168,32 +124,156 @@ 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 }))
// Create subcategories based on template types and content
// 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')
// USE CASES SUBCATEGORIES
const imageCreationTemplates = allTemplates.filter((template) =>
['Basics', 'Flux', 'Image'].includes(template.title)
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
)
const videoAnimationTemplates = allTemplates.filter(
(template) => template.title === 'Video'
)
const spatialTemplates = allTemplates.filter((template) =>
['3D', 'Area Composition'].includes(template.title)
)
const audioTemplates = allTemplates.filter(
(template) => template.title === 'Audio'
)
// TOOLS & BUILDING SUBCATEGORIES
const advancedApisTemplates = allTemplates.filter((template) =>
['Image API', 'Video API', '3D API', 'LLM API'].includes(template.title)
)
const postProcessingTemplates = allTemplates.filter((template) =>
['Upscaling', 'ControlNet'].includes(template.title)
)
// CUSTOM & COMMUNITY SUBCATEGORIES
const customNodesTemplates = allTemplates.filter(
(template) =>
template.moduleName !== 'default' &&
!advancedApisTemplates.includes(template)
)
const communityPicksTemplates: WorkflowTemplates[] = [] // This could be populated with featured community content
const groups: TemplateGroup[] = []
// USE CASES GROUP
const useCasesSubcategories = []
if (imageCreationTemplates.length > 0) {
useCasesSubcategories.push({
label: st(
'templateWorkflows.subcategory.imageCreation',
'Image Creation'
),
modules: imageCreationTemplates
})
}
return groupedByCategory
if (videoAnimationTemplates.length > 0) {
useCasesSubcategories.push({
label: st(
'templateWorkflows.subcategory.videoAnimation',
'Video & Animation'
),
modules: videoAnimationTemplates
})
}
if (spatialTemplates.length > 0) {
useCasesSubcategories.push({
label: st('templateWorkflows.subcategory.spatial', '3D & Spatial'),
modules: spatialTemplates
})
}
if (audioTemplates.length > 0) {
useCasesSubcategories.push({
label: st('templateWorkflows.subcategory.audio', 'Audio'),
modules: audioTemplates
})
}
if (useCasesSubcategories.length > 0) {
groups.push({
label: st('templateWorkflows.group.useCases', 'USE CASES'),
subcategories: useCasesSubcategories
})
}
// TOOLS & BUILDING GROUP
const toolsBuildingSubcategories = []
if (advancedApisTemplates.length > 0) {
toolsBuildingSubcategories.push({
label: st(
'templateWorkflows.subcategory.advancedApis',
'Advanced APIs'
),
modules: advancedApisTemplates
})
}
if (postProcessingTemplates.length > 0) {
toolsBuildingSubcategories.push({
label: st(
'templateWorkflows.subcategory.postProcessing',
'Post-Processing & Utilities'
),
modules: postProcessingTemplates
})
}
if (toolsBuildingSubcategories.length > 0) {
groups.push({
label: st(
'templateWorkflows.group.toolsBuilding',
'TOOLS & BUILDING'
),
subcategories: toolsBuildingSubcategories
})
}
// CUSTOM & COMMUNITY GROUP
const customCommunitySubcategories = []
if (customNodesTemplates.length > 0) {
customCommunitySubcategories.push({
label: st(
'templateWorkflows.subcategory.customNodes',
'Custom Nodes'
),
modules: customNodesTemplates
})
}
if (communityPicksTemplates.length > 0) {
customCommunitySubcategories.push({
label: st(
'templateWorkflows.subcategory.communityPicks',
'Community Picks'
),
modules: communityPicksTemplates
})
}
if (customCommunitySubcategories.length > 0) {
groups.push({
label: st(
'templateWorkflows.group.customCommunity',
'CUSTOM & COMMUNITY'
),
subcategories: customCommunitySubcategories
})
}
return groups
})
async function loadWorkflowTemplates() {

View File

@@ -12,16 +12,25 @@ export interface TemplateInfo {
localizedTitle?: string
localizedDescription?: string
sourceModule?: string
tags?: string[]
models?: string[]
date?: string
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
localizedTitle?: string
}
export interface TemplateSubcategory {
label: string
modules: WorkflowTemplates[]
}
export interface TemplateGroup {
label: string
icon?: string
modules: WorkflowTemplates[]
subcategories: TemplateSubcategory[]
}

View File

@@ -49,7 +49,7 @@ export default defineConfig({
},
'/workflow_templates': {
target: DEV_SERVER_COMFYUI_URL
target: 'http://localhost:1238'
},
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
@@ -67,7 +67,7 @@ export default defineConfig({
...(!DISABLE_TEMPLATES_PROXY
? {
'/templates': {
target: DEV_SERVER_COMFYUI_URL
target: 'http://localhost:1238'
}
}
: {}),