Skip to content

Commit 5e9b878

Browse files
authored
Scroll templates better (#4584)
1 parent 386eb93 commit 5e9b878

24 files changed

+1045
-119
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<template>
2+
<div
3+
ref="containerRef"
4+
class="relative overflow-hidden w-full h-full flex items-center justify-center"
5+
>
6+
<Skeleton
7+
v-if="!isImageLoaded"
8+
width="100%"
9+
height="100%"
10+
class="absolute inset-0"
11+
/>
12+
<img
13+
v-show="isImageLoaded"
14+
ref="imageRef"
15+
:src="cachedSrc"
16+
:alt="alt"
17+
draggable="false"
18+
:class="imageClass"
19+
:style="imageStyle"
20+
@load="onImageLoad"
21+
@error="onImageError"
22+
/>
23+
<div
24+
v-if="hasError"
25+
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
26+
>
27+
<i class="pi pi-image text-2xl" />
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script setup lang="ts">
33+
import Skeleton from 'primevue/skeleton'
34+
import { computed, onUnmounted, ref, watch } from 'vue'
35+
36+
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
37+
import { useMediaCache } from '@/services/mediaCacheService'
38+
39+
const {
40+
src,
41+
alt = '',
42+
imageClass = '',
43+
imageStyle,
44+
rootMargin = '300px'
45+
} = defineProps<{
46+
src: string
47+
alt?: string
48+
imageClass?: string | string[] | Record<string, boolean>
49+
imageStyle?: Record<string, any>
50+
rootMargin?: string
51+
}>()
52+
53+
const containerRef = ref<HTMLElement | null>(null)
54+
const imageRef = ref<HTMLImageElement | null>(null)
55+
const isIntersecting = ref(false)
56+
const isImageLoaded = ref(false)
57+
const hasError = ref(false)
58+
const cachedSrc = ref<string | undefined>(undefined)
59+
60+
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
61+
62+
// Use intersection observer to detect when the image container comes into view
63+
useIntersectionObserver(
64+
containerRef,
65+
(entries) => {
66+
const entry = entries[0]
67+
isIntersecting.value = entry?.isIntersecting ?? false
68+
},
69+
{
70+
rootMargin,
71+
threshold: 0.1
72+
}
73+
)
74+
75+
// Only start loading the image when it's in view
76+
const shouldLoad = computed(() => isIntersecting.value)
77+
78+
watch(
79+
shouldLoad,
80+
async (shouldLoad) => {
81+
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
82+
try {
83+
const cachedMedia = await getCachedMedia(src)
84+
if (cachedMedia.error) {
85+
hasError.value = true
86+
} else if (cachedMedia.objectUrl) {
87+
const acquiredUrl = acquireUrl(src)
88+
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
89+
} else {
90+
cachedSrc.value = src
91+
}
92+
} catch (error) {
93+
console.warn('Failed to load cached media:', error)
94+
cachedSrc.value = src
95+
}
96+
} else if (!shouldLoad) {
97+
if (cachedSrc.value?.startsWith('blob:')) {
98+
releaseUrl(src)
99+
}
100+
// Hide image when out of view
101+
isImageLoaded.value = false
102+
cachedSrc.value = undefined
103+
hasError.value = false
104+
}
105+
},
106+
{ immediate: true }
107+
)
108+
109+
const onImageLoad = () => {
110+
isImageLoaded.value = true
111+
hasError.value = false
112+
}
113+
114+
const onImageError = () => {
115+
hasError.value = true
116+
isImageLoaded.value = false
117+
}
118+
119+
onUnmounted(() => {
120+
if (cachedSrc.value?.startsWith('blob:')) {
121+
releaseUrl(src)
122+
}
123+
})
124+
</script>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<template>
2+
<div class="relative w-full p-4">
3+
<div class="h-12 flex items-center gap-4 justify-between">
4+
<div class="flex-1 max-w-md">
5+
<AutoComplete
6+
v-model.lazy="searchQuery"
7+
:placeholder="$t('templateWorkflows.searchPlaceholder')"
8+
:complete-on-focus="false"
9+
:delay="200"
10+
class="w-full"
11+
:pt="{
12+
pcInputText: {
13+
root: {
14+
class: 'w-full rounded-2xl'
15+
}
16+
},
17+
loader: {
18+
style: 'display: none'
19+
}
20+
}"
21+
:show-empty-message="false"
22+
@complete="() => {}"
23+
/>
24+
</div>
25+
</div>
26+
27+
<div class="flex items-center gap-4 mt-2">
28+
<small
29+
v-if="searchQuery && filteredCount !== null"
30+
class="text-color-secondary"
31+
>
32+
{{ $t('g.resultsCount', { count: filteredCount }) }}
33+
</small>
34+
<Button
35+
v-if="searchQuery"
36+
text
37+
size="small"
38+
icon="pi pi-times"
39+
:label="$t('g.clearFilters')"
40+
@click="clearFilters"
41+
/>
42+
</div>
43+
</div>
44+
</template>
45+
46+
<script setup lang="ts">
47+
import AutoComplete from 'primevue/autocomplete'
48+
import Button from 'primevue/button'
49+
50+
const { filteredCount } = defineProps<{
51+
filteredCount?: number | null
52+
}>()
53+
54+
const searchQuery = defineModel<string>('searchQuery', { default: '' })
55+
56+
const emit = defineEmits<{
57+
clearFilters: []
58+
}>()
59+
60+
const clearFilters = () => {
61+
searchQuery.value = ''
62+
emit('clearFilters')
63+
}
64+
</script>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<template>
2+
<Card
3+
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
4+
:pt="{
5+
body: { class: 'p-0 h-full flex flex-col' }
6+
}"
7+
>
8+
<template #header>
9+
<div class="flex items-center justify-center">
10+
<div class="relative overflow-hidden rounded-t-lg">
11+
<Skeleton width="16rem" height="12rem" />
12+
</div>
13+
</div>
14+
</template>
15+
<template #content>
16+
<div class="flex items-center px-4 py-3">
17+
<div class="flex-1 flex flex-col">
18+
<Skeleton width="80%" height="1.25rem" class="mb-2" />
19+
<Skeleton width="100%" height="0.875rem" class="mb-1" />
20+
<Skeleton width="90%" height="0.875rem" />
21+
</div>
22+
</div>
23+
</template>
24+
</Card>
25+
</template>
26+
27+
<script setup lang="ts">
28+
import Card from 'primevue/card'
29+
import Skeleton from 'primevue/skeleton'
30+
</script>

src/components/templates/TemplateWorkflowView.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mount } from '@vue/test-utils'
22
import { describe, expect, it, vi } from 'vitest'
3+
import { createI18n } from 'vue-i18n'
34

45
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
56
import { TemplateInfo } from '@/types/workflowTemplateTypes'
@@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
5354
}
5455
}))
5556

57+
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
58+
default: {
59+
template: '<div class="mock-search-bar"></div>',
60+
props: ['searchQuery', 'filteredCount'],
61+
emits: ['update:searchQuery', 'clearFilters']
62+
}
63+
}))
64+
65+
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
66+
default: {
67+
template: '<div class="mock-skeleton"></div>'
68+
}
69+
}))
70+
5671
vi.mock('@vueuse/core', () => ({
5772
useLocalStorage: () => 'grid'
5873
}))
5974

75+
vi.mock('@/composables/useIntersectionObserver', () => ({
76+
useIntersectionObserver: vi.fn()
77+
}))
78+
79+
vi.mock('@/composables/useLazyPagination', () => ({
80+
useLazyPagination: (items: any) => ({
81+
paginatedItems: items,
82+
isLoading: { value: false },
83+
hasMoreItems: { value: false },
84+
loadNextPage: vi.fn(),
85+
reset: vi.fn()
86+
})
87+
}))
88+
89+
vi.mock('@/composables/useTemplateFiltering', () => ({
90+
useTemplateFiltering: (templates: any) => ({
91+
searchQuery: { value: '' },
92+
filteredTemplates: templates,
93+
filteredCount: { value: templates.value?.length || 0 }
94+
})
95+
}))
96+
6097
describe('TemplateWorkflowView', () => {
6198
const createTemplate = (name: string): TemplateInfo => ({
6299
name,
@@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => {
67104
})
68105

69106
const mountView = (props = {}) => {
107+
const i18n = createI18n({
108+
legacy: false,
109+
locale: 'en',
110+
messages: {
111+
en: {
112+
templateWorkflows: {
113+
loadingMore: 'Loading more...'
114+
}
115+
}
116+
}
117+
})
118+
70119
return mount(TemplateWorkflowView, {
71120
props: {
72121
title: 'Test Templates',
@@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
79128
],
80129
loading: null,
81130
...props
131+
},
132+
global: {
133+
plugins: [i18n]
82134
}
83135
})
84136
}

0 commit comments

Comments
 (0)