diff --git a/src/components/common/LazyImage.vue b/src/components/common/LazyImage.vue new file mode 100644 index 0000000000..79c7320f64 --- /dev/null +++ b/src/components/common/LazyImage.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/components/templates/TemplateSearchBar.vue b/src/components/templates/TemplateSearchBar.vue new file mode 100644 index 0000000000..17b564b126 --- /dev/null +++ b/src/components/templates/TemplateSearchBar.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/components/templates/TemplateWorkflowCardSkeleton.vue b/src/components/templates/TemplateWorkflowCardSkeleton.vue new file mode 100644 index 0000000000..00bf738398 --- /dev/null +++ b/src/components/templates/TemplateWorkflowCardSkeleton.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/templates/TemplateWorkflowView.spec.ts b/src/components/templates/TemplateWorkflowView.spec.ts index a70e828a56..6860797c64 100644 --- a/src/components/templates/TemplateWorkflowView.spec.ts +++ b/src/components/templates/TemplateWorkflowView.spec.ts @@ -1,5 +1,6 @@ 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' @@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({ } })) +vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({ + default: { + template: '', + props: ['searchQuery', 'filteredCount'], + emits: ['update:searchQuery', 'clearFilters'] + } +})) + +vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({ + default: { + template: '
' + } +})) + 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, @@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => { }) const mountView = (props = {}) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + templateWorkflows: { + loadingMore: 'Loading more...' + } + } + } + }) + return mount(TemplateWorkflowView, { props: { title: 'Test Templates', @@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => { ], loading: null, ...props + }, + global: { + plugins: [i18n] } }) } diff --git a/src/components/templates/TemplateWorkflowView.vue b/src/components/templates/TemplateWorkflowView.vue index 174a91201f..8a866cdd17 100644 --- a/src/components/templates/TemplateWorkflowView.vue +++ b/src/components/templates/TemplateWorkflowView.vue @@ -1,24 +1,31 @@ @@ -54,12 +78,21 @@ 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 '@/types/workflowTemplateTypes' -defineProps<{ +const { t } = useI18n() + +const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{ title: string sourceModule: string categoryTitle: string @@ -72,6 +105,59 @@ const layout = useLocalStorage<'grid' | 'list'>( 'grid' ) +const skeletonCount = 6 +const loadTrigger = ref(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] }>() diff --git a/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts b/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts index 7d0fcc9c9d..681d812384 100644 --- a/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts +++ b/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts @@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({ } })) +vi.mock('@/components/common/LazyImage.vue', () => ({ + default: { + name: 'LazyImage', + template: + '', + props: ['src', 'alt', 'imageClass', 'imageStyle'] + } +})) + vi.mock('@vueuse/core', () => ({ useMouseInElement: () => ({ elementX: ref(50), @@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => { it('renders both base and overlay images', () => { const wrapper = mountThumbnail() - const images = wrapper.findAll('img') - expect(images.length).toBe(2) - expect(images[0].attributes('src')).toBe('/base-image.jpg') - expect(images[1].attributes('src')).toBe('/overlay-image.jpg') + const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' }) + expect(lazyImages.length).toBe(2) + expect(lazyImages[0].props('src')).toBe('/base-image.jpg') + expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg') }) it('applies correct alt text to both images', () => { const wrapper = mountThumbnail({ alt: 'Custom Alt Text' }) - const images = wrapper.findAll('img') - expect(images[0].attributes('alt')).toBe('Custom Alt Text') - expect(images[1].attributes('alt')).toBe('Custom Alt Text') + const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' }) + expect(lazyImages[0].props('alt')).toBe('Custom Alt Text') + expect(lazyImages[1].props('alt')).toBe('Custom Alt Text') }) it('applies clip-path style to overlay image', () => { const wrapper = mountThumbnail() - const overlay = wrapper.findAll('img')[1] - expect(overlay.attributes('style')).toContain('clip-path') + const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1] + const imageStyle = overlayLazyImage.props('imageStyle') + expect(imageStyle.clipPath).toContain('inset') }) it('renders slider divider', () => { diff --git a/src/components/templates/thumbnails/CompareSliderThumbnail.vue b/src/components/templates/thumbnails/CompareSliderThumbnail.vue index 3a6d0e3a2a..3633c5dc52 100644 --- a/src/components/templates/thumbnails/CompareSliderThumbnail.vue +++ b/src/components/templates/thumbnails/CompareSliderThumbnail.vue @@ -1,24 +1,24 @@