Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/components/common/LazyImage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<template>
<div
ref="containerRef"
class="relative overflow-hidden w-full h-full flex items-center justify-center"
>
<Skeleton
v-if="!isImageLoaded"
width="100%"
height="100%"
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>

<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
const {
src,
alt = '',
imageClass = '',
imageStyle,
rootMargin = '50px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia } = useMediaCache()
// Use intersection observer to detect when the image container comes into view
useIntersectionObserver(
containerRef,
(entries) => {
const entry = entries[0]
isIntersecting.value = entry?.isIntersecting ?? false
},
{
rootMargin,
threshold: 0.1
}
)
// Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value)
// Watch for when we should load and handle caching
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
hasError.value = true
} else if (cachedMedia.objectUrl) {
cachedSrc.value = cachedMedia.objectUrl
} else {
cachedSrc.value = src
}
} catch (error) {
console.warn('Failed to load cached media:', error)
// Fallback to original src
cachedSrc.value = src
}
} else if (!shouldLoad) {
// Hide image when out of view
isImageLoaded.value = false
cachedSrc.value = undefined
hasError.value = false
}
},
{ immediate: true }
)
const onImageLoad = () => {
isImageLoaded.value = true
hasError.value = false
}
const onImageError = () => {
hasError.value = true
isImageLoaded.value = false
}
</script>
64 changes: 64 additions & 0 deletions src/components/templates/TemplateSearchBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<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'
}
},
loader: {
style: 'display: none'
}
}"
: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"
/>
</div>
</div>
</template>

<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const { filteredCount } = defineProps<{
filteredCount?: number | null
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
clearFilters: []
}>()
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
}
</script>
30 changes: 30 additions & 0 deletions src/components/templates/TemplateWorkflowCardSkeleton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>

<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>
52 changes: 52 additions & 0 deletions src/components/templates/TemplateWorkflowView.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -53,10 +54,46 @@ 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']
}
}))

vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))

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,
Expand All @@ -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',
Expand All @@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
],
loading: null,
...props
},
global: {
plugins: [i18n]
}
})
}
Expand Down
Loading
Loading