From 5e9b8785a5b2c2599728e3cd9e5f42954e67a590 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Thu, 7 Aug 2025 00:13:29 +0100 Subject: [PATCH] Scroll templates better (#4584) --- src/components/common/LazyImage.vue | 124 ++++++++++ .../templates/TemplateSearchBar.vue | 64 +++++ .../TemplateWorkflowCardSkeleton.vue | 30 +++ .../templates/TemplateWorkflowView.spec.ts | 52 ++++ .../templates/TemplateWorkflowView.vue | 138 +++++++++-- .../thumbnails/CompareSliderThumbnail.spec.ts | 28 ++- .../thumbnails/CompareSliderThumbnail.vue | 11 +- .../thumbnails/DefaultThumbnail.spec.ts | 67 ++++-- .../templates/thumbnails/DefaultThumbnail.vue | 30 ++- .../thumbnails/HoverDissolveThumbnail.spec.ts | 71 ++++-- .../thumbnails/HoverDissolveThumbnail.vue | 41 ++-- src/composables/useIntersectionObserver.ts | 60 +++++ src/composables/useLazyPagination.ts | 107 +++++++++ src/composables/useTemplateFiltering.ts | 56 +++++ src/locales/en/main.json | 3 + src/locales/es/main.json | 3 + src/locales/fr/main.json | 3 + src/locales/ja/main.json | 3 + src/locales/ko/main.json | 3 + src/locales/ru/main.json | 3 + src/locales/zh-TW/main.json | 3 + src/locales/zh/main.json | 3 + src/services/mediaCacheService.ts | 226 ++++++++++++++++++ .../tests/services/mediaCacheService.test.ts | 35 +++ 24 files changed, 1045 insertions(+), 119 deletions(-) create mode 100644 src/components/common/LazyImage.vue create mode 100644 src/components/templates/TemplateSearchBar.vue create mode 100644 src/components/templates/TemplateWorkflowCardSkeleton.vue create mode 100644 src/composables/useIntersectionObserver.ts create mode 100644 src/composables/useLazyPagination.ts create mode 100644 src/composables/useTemplateFiltering.ts create mode 100644 src/services/mediaCacheService.ts create mode 100644 tests-ui/tests/services/mediaCacheService.test.ts 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 @@