Skip to content

Commit

Permalink
Merge pull request #1488 from PrefectHQ/virtual-scroller-orion-design
Browse files Browse the repository at this point in the history
UI: Subscriptions & Virtual Scrolling
  • Loading branch information
pleek91 authored Mar 22, 2022
2 parents e00db0a + 4954f18 commit c7030e0
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<template>
<div class="flows-list">
<m-card shadow="sm">
<template v-for="flow in flows" :key="flow.id">
<FlowsPageFlowListItem :flow="flow" />
</template>
<VirtualScroller :items="flows" :item-estimate-height="70" @bottom="emit('bottom')">
<template #default="{ item }">
<FlowsPageFlowListItem :flow="item" />
</template>
</VirtualScroller>
</m-card>
<template v-if="empty">
<div class="text-center my-8">
Expand All @@ -15,12 +17,17 @@

<script lang="ts" setup>
import { computed } from 'vue'
import VirtualScroller from './VirtualScroller.vue'
import FlowsPageFlowListItem from '@/components/FlowsPageFlowListItem.vue'
import { Flow } from '@/models/Flow'
const props = defineProps<{
flows: Flow[],
}>()
const emit = defineEmits<{
(event: 'bottom'): void,
}>()
const empty = computed(() => props.flows.length === 0)
</script>
71 changes: 71 additions & 0 deletions orion-ui/packages/orion-design/src/components/VirtualScroller.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<div class="virtual-scroller">
<template v-for="(chunk, index) in chunks" :key="index">
<VirtualScrollerChunk :height="itemEstimateHeight * chunk.length" v-bind="{ observerOptions }">
<template v-for="(item, itemIndex) in chunk" :key="itemIndex">
<slot :item="item as any" />
</template>
</VirtualScrollerChunk>
</template>
<div ref="bottom" class="virtual-scroller__bottom" />
</div>
</template>

<script lang="ts" setup>
import { computed, onMounted, ref, watch, withDefaults } from 'vue'
import VirtualScrollerChunk from './VirtualScrollerChunk.vue'
import { useIntersectionObserver } from '@/compositions/useIntersectionObserver'
const props = withDefaults(defineProps<{
// any is the correct type here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items: Record<string, any>[],
itemEstimateHeight?: number,
chunkSize?: number,
observerOptions?: IntersectionObserverInit,
}>(), {
itemEstimateHeight: 50,
chunkSize: 50,
observerOptions: () =>({
rootMargin: '200px',
}),
})
const emit = defineEmits<{
(event: 'bottom'): void,
}>()
const bottom = ref<HTMLDivElement>()
const { observe, check } = useIntersectionObserver(intersect, props.observerOptions)
const chunks = computed(() => {
const chunks = []
const source = props.items
for (let i = 0; i < source.length; i += props.chunkSize) {
chunks.push(source.slice(i, i + props.chunkSize))
}
return chunks
})
function intersect(entries: IntersectionObserverEntry[]): void {
entries.forEach(entry => {
if (entry.isIntersecting) {
emit('bottom')
}
})
}
watch(() => props.items, (current, previous) => {
if (previous.length >= current.length) {
return
}
check(bottom)
})
onMounted(() => {
observe(bottom)
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<template>
<div ref="el" class="virtual-scroller-chunk" :style="styles">
<template v-if="visible">
<slot />
</template>
</div>
</template>

<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useIntersectionObserver } from '@/compositions/useIntersectionObserver'
const props = defineProps<{
height: number,
observerOptions: IntersectionObserverInit,
}>()
const styles = computed(() => ({
height: !visible.value ? `${height.value ?? props.height}px` : undefined,
}))
const el = ref<HTMLDivElement>()
const visible = ref(false)
const height = ref<number | null>(null)
const { observe } = useIntersectionObserver(intersect, props.observerOptions)
function setHeight(): void {
setTimeout(() => {
const rect = el.value!.getBoundingClientRect()
height.value = rect.height
})
}
function intersect(entries: IntersectionObserverEntry[]): void {
entries.forEach(entry => {
if (!entry.isIntersecting) {
setHeight()
}
setTimeout(() => {
visible.value = entry.isIntersecting
})
})
}
onMounted(() => {
observe(el)
})
</script>
2 changes: 2 additions & 0 deletions orion-ui/packages/orion-design/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export { default as TabSet } from './TabSet.vue'
export { default as TagsInput } from './TagsInput.vue'
export { default as TaskRunLink } from './TaskRunLink.vue'
export { default as ValidationMessage } from './ValidationMessage.vue'
export { default as VirtualScroller } from './VirtualScroller.vue'
export { default as VirtualScrollerChunk } from './VirtualScrollerChunk.vue'
export { default as WorkQueueCreateButton } from './WorkQueueCreateButton.vue'
export { default as WorkQueueCreatePanel } from './WorkQueueCreatePanel.vue'
export { default as WorkQueueEditPanel } from './WorkQueueEditPanel.vue'
Expand Down
4 changes: 3 additions & 1 deletion orion-ui/packages/orion-design/src/compositions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './useInjectedServices'
export * from './useInjectedServices'
export * from './useIntersectionObserver'
export * from './useSubscriptionWithPaging'
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { onMounted, onUnmounted, Ref } from 'vue'

type useIntersectionObserverResponse = {
observe: (element: Ref<HTMLElement | undefined>) => void,
unobserve: (element: Ref<HTMLElement | undefined>) => void,
disconnect: () => void,
check: (element: Ref<HTMLElement | undefined>) => void,
}

export function useIntersectionObserver(callback: (entries: IntersectionObserverEntry[]) => void, options: IntersectionObserverInit): useIntersectionObserverResponse {

let intersectionObserver: IntersectionObserver | null = null

function observe(element: Ref<HTMLElement | undefined>): void {
const observer = getObserver()

if (element.value) {
observer.observe(element.value)
}
}

function unobserve(element: Ref<HTMLElement | undefined>): void {
const observer = getObserver()

if (element.value) {
observer.unobserve(element.value)
}
}

function disconnect(): void {
const observer = getObserver()

observer.disconnect()
}

function check(element: Ref<HTMLElement | undefined>): void {
if (!element.value) {
return
}

const observer = new IntersectionObserver(callback, options)

observer.observe(element.value)

setTimeout(() => observer.disconnect(), 100)
}

function getObserver(): IntersectionObserver {
if (!intersectionObserver) {
createObserver()
}

return intersectionObserver!
}

function createObserver(): void {
intersectionObserver = new IntersectionObserver(callback, options)
}

onMounted(() => {
createObserver()
})

onUnmounted(() => {
disconnect()
})

return {
observe,
disconnect,
unobserve,
check,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@

import { useSubscription } from '@prefecthq/vue-compositions'
import Subscription from '@prefecthq/vue-compositions/src/subscribe/subscription'
import { ActionArguments, ActionResponse, SubscriptionOptions } from '@prefecthq/vue-compositions/src/subscribe/types'
import { unrefArgs, watchableArgs } from '@prefecthq/vue-compositions/src/subscribe/utilities'
import { computed, isReactive, isRef, reactive, ref, unref, watch } from 'vue'
import { UnionFilters } from '@/services/Filter'

type UnionFiltersAction = (filters: UnionFilters) => any[] | Promise<any[]>
type UnionFiltersActionResponse<T extends UnionFiltersAction> = {
loadMore: () => void,
response: ActionResponse<T>,
}

export function useSubscriptionWithPaging<T extends UnionFiltersAction>(
action: T,
args: ActionArguments<T>,
options: SubscriptionOptions = {},
): UnionFiltersActionResponse<T> {
const subscriptions = reactive<Subscription<T>[]>([])
const pages = ref(0)

const response = computed<ActionResponse<T>>(() => {
const acc = [] as ActionResponse<T>

return subscriptions.reduce((acc, subscription) => {
const response = (subscription.response as any).value ?? []

return [...acc, ...response] as ActionResponse<T>
}, acc)
})

const loadMore = (): void => {
const [unwrappedFilters] = unrefArgs(args)
const limit = unwrappedFilters.limit ?? 200

if (subscriptions.length * limit > response.value.length) {
return
}

const offset = (unwrappedFilters.offset ?? limit) * pages.value
const subscriptionFilters = [{ ...unwrappedFilters, offset, limit }] as Parameters<T>
const subscription = useSubscription<T>(action, subscriptionFilters, options)

subscriptions.push(reactive(subscription))

pages.value++
}

if (
isRef(args) ||
isReactive(args) ||
(unref(args) as Parameters<T>).some(isRef) ||
(unref(args) as Parameters<T>).some(isReactive)
) {
const argsToWatch = watchableArgs(args)

watch(argsToWatch, () => {
pages.value = 0
subscriptions.forEach(subscription => subscription.unsubscribe())
subscriptions.splice(0)

loadMore()
})
}

return reactive({
response,
loadMore,
})

}

0 comments on commit c7030e0

Please sign in to comment.