-
Notifications
You must be signed in to change notification settings - Fork 9.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Improve workflow list performance using RecycleScroller and on-…
…demand sharing data loading (#5181) * feat(editor): Load workflow sharedWith info only when opening share modal (#5125) * feat(editor): load workflow sharedWith info only when opening share modal * fix(editor): update workflow share modal loading state at the end of initialize fn * feat: initial recycle scroller commit * feat: prepare recycle scroller for dynamic item sizes (no-changelog) * feat: add recycle scroller with variable size support and caching * feat: integrated recycle scroller with existing resources list * feat: improve recycle scroller performance * fix: fix recycle-scroller storybook * fix: update recycle-scroller styles to fix scrollbar size * chore: undo vite config changes * chore: undo installed packages * chore: remove commented code * chore: remove vue-virtual-scroller code. * feat: update size cache updating mechanism * chore: remove console.log * fix: adjust code for e2e tests * fix: fix linting issues
- Loading branch information
1 parent
8ce85e3
commit 874c735
Showing
15 changed files
with
470 additions
and
71 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.stories.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ | ||
import type { StoryFn } from '@storybook/vue'; | ||
import N8nRecycleScroller from './RecycleScroller.vue'; | ||
import { ComponentInstance } from 'vue'; | ||
|
||
export default { | ||
title: 'Atoms/RecycleScroller', | ||
component: N8nRecycleScroller, | ||
argTypes: {}, | ||
}; | ||
|
||
const Template: StoryFn = () => ({ | ||
components: { | ||
N8nRecycleScroller, | ||
}, | ||
data() { | ||
return { | ||
items: Array.from(Array(256).keys()).map((i) => ({ id: i })) as Array<{ | ||
id: number; | ||
height: number; | ||
}>, | ||
}; | ||
}, | ||
methods: { | ||
resizeItem(item: { id: string; height: string }, fn: (item: { id: string }) => void) { | ||
const itemRef = (this as ComponentInstance).$refs[`item-${item.id}`] as HTMLElement; | ||
|
||
item.height = '200px'; | ||
itemRef.style.height = '200px'; | ||
fn(item); | ||
}, | ||
getItemStyle(item: { id: string; height?: string }) { | ||
return { | ||
height: item.height || '100px', | ||
width: '100%', | ||
backgroundColor: `hsl(${parseInt(item.id, 10) * 1.4}, 100%, 50%)`, | ||
cursor: 'pointer', | ||
display: 'flex', | ||
alignItems: 'center', | ||
justifyContent: 'center', | ||
}; | ||
}, | ||
}, | ||
template: `<div style="height: calc(100vh - 30px); width: 100%; overflow: auto"> | ||
<N8nRecycleScroller :items="items" :item-size="100" item-key="id" v-bind="$props"> | ||
<template #default="{ item, updateItemSize }"> | ||
<div | ||
:ref="'item-' + item.id" | ||
:style="getItemStyle(item)" | ||
@click="resizeItem(item, updateItemSize)" | ||
> | ||
{{item.id}} | ||
</div> | ||
</template> | ||
</N8nRecycleScroller> | ||
</div>`, | ||
}); | ||
|
||
export const RecycleScroller = Template.bind({}); |
274 changes: 274 additions & 0 deletions
274
packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,274 @@ | ||
<script lang="ts"> | ||
/* eslint-disable @typescript-eslint/no-use-before-define */ | ||
import { | ||
computed, | ||
defineComponent, | ||
onMounted, | ||
onBeforeMount, | ||
ref, | ||
PropType, | ||
nextTick, | ||
watch, | ||
} from 'vue'; | ||
export default defineComponent({ | ||
name: 'n8n-recycle-scroller', | ||
props: { | ||
itemSize: { | ||
type: Number, | ||
required: true, | ||
}, | ||
items: { | ||
type: Array as PropType<Array<Record<string, string>>>, | ||
required: true, | ||
}, | ||
itemKey: { | ||
type: String, | ||
required: true, | ||
}, | ||
offset: { | ||
type: Number, | ||
default: 2, | ||
}, | ||
}, | ||
setup(props) { | ||
const wrapperRef = ref<HTMLElement | null>(null); | ||
const scrollerRef = ref<HTMLElement | null>(null); | ||
const itemsRef = ref<HTMLElement | null>(null); | ||
const itemRefs = ref<Record<string, HTMLElement | null>>({}); | ||
const scrollTop = ref(0); | ||
const wrapperHeight = ref(0); | ||
const windowHeight = ref(0); | ||
const itemCount = computed(() => props.items.length); | ||
/** | ||
* Cache | ||
*/ | ||
const itemSizeCache = ref<Record<string, number>>({}); | ||
const itemPositionCache = computed(() => { | ||
return props.items.reduce<Record<string, number>>((acc, item, index) => { | ||
const key = item[props.itemKey]; | ||
const prevItem = props.items[index - 1]; | ||
const prevItemPosition = prevItem ? acc[prevItem[props.itemKey]] : 0; | ||
const prevItemSize = prevItem ? itemSizeCache.value[prevItem[props.itemKey]] : 0; | ||
acc[key] = prevItemPosition + prevItemSize; | ||
return acc; | ||
}, {}); | ||
}); | ||
/** | ||
* Indexes | ||
*/ | ||
const startIndex = computed(() => { | ||
const foundIndex = | ||
props.items.findIndex((item) => { | ||
const itemPosition = itemPositionCache.value[item[props.itemKey]]; | ||
return itemPosition >= scrollTop.value; | ||
}) - 1; | ||
const index = foundIndex - props.offset; | ||
return index < 0 ? 0 : index; | ||
}); | ||
const endIndex = computed(() => { | ||
const foundIndex = props.items.findIndex((item) => { | ||
const itemPosition = itemPositionCache.value[item[props.itemKey]]; | ||
const itemSize = itemSizeCache.value[item[props.itemKey]]; | ||
return itemPosition + itemSize >= scrollTop.value + wrapperHeight.value; | ||
}); | ||
const index = foundIndex + props.offset; | ||
return index === -1 ? props.items.length - 1 : index; | ||
}); | ||
const visibleItems = computed(() => { | ||
return props.items.slice(startIndex.value, endIndex.value + 1); | ||
}); | ||
watch( | ||
() => visibleItems.value, | ||
(currentValue, previousValue) => { | ||
const difference = currentValue.filter( | ||
(currentItem) => | ||
!previousValue.find( | ||
(previousItem) => previousItem[props.itemKey] === currentItem[props.itemKey], | ||
), | ||
); | ||
if (difference.length > 0) { | ||
updateItemSizeCache(difference); | ||
} | ||
}, | ||
); | ||
/** | ||
* Computed sizes and styles | ||
*/ | ||
const scrollerHeight = computed(() => { | ||
const lastItem = props.items[props.items.length - 1]; | ||
const lastItemPosition = lastItem ? itemPositionCache.value[lastItem[props.itemKey]] : 0; | ||
const lastItemSize = lastItem ? itemSizeCache.value[lastItem[props.itemKey]] : props.itemSize; | ||
return lastItemPosition + lastItemSize; | ||
}); | ||
const scrollerStyles = computed(() => ({ | ||
height: `${scrollerHeight.value}px`, | ||
})); | ||
const itemsStyles = computed(() => { | ||
const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]]; | ||
return { | ||
transform: `translateY(${offset}px)`, | ||
}; | ||
}); | ||
/** | ||
* Lifecycle hooks | ||
*/ | ||
onBeforeMount(() => { | ||
initializeItemSizeCache(); | ||
}); | ||
onMounted(() => { | ||
if (wrapperRef.value) { | ||
wrapperRef.value.addEventListener('scroll', onScroll); | ||
updateItemSizeCache(visibleItems.value); | ||
} | ||
window.addEventListener('resize', onWindowResize); | ||
onWindowResize(); | ||
}); | ||
/** | ||
* Event handlers | ||
*/ | ||
function initializeItemSizeCache() { | ||
props.items.forEach((item) => { | ||
itemSizeCache.value = { | ||
...itemSizeCache.value, | ||
[item[props.itemKey]]: props.itemSize, | ||
}; | ||
}); | ||
} | ||
function updateItemSizeCache(items: Array<Record<string, string>>) { | ||
for (const item of items) { | ||
onUpdateItemSize(item); | ||
} | ||
} | ||
function onUpdateItemSize(item: { [key: string]: string }) { | ||
nextTick(() => { | ||
const itemId = item[props.itemKey]; | ||
const itemRef = itemRefs.value[itemId]; | ||
const previousSize = itemSizeCache.value[itemId]; | ||
const size = itemRef ? itemRef.offsetHeight : props.itemSize; | ||
const difference = size - previousSize; | ||
itemSizeCache.value = { | ||
...itemSizeCache.value, | ||
[item[props.itemKey]]: size, | ||
}; | ||
if (wrapperRef.value && scrollTop.value) { | ||
wrapperRef.value.scrollTop = wrapperRef.value.scrollTop + difference; | ||
scrollTop.value = wrapperRef.value.scrollTop; | ||
} | ||
}); | ||
} | ||
function onWindowResize() { | ||
if (wrapperRef.value) { | ||
wrapperHeight.value = wrapperRef.value.offsetHeight; | ||
nextTick(() => { | ||
updateItemSizeCache(visibleItems.value); | ||
}); | ||
} | ||
windowHeight.value = window.innerHeight; | ||
} | ||
function onScroll() { | ||
if (!wrapperRef.value) { | ||
return; | ||
} | ||
scrollTop.value = wrapperRef.value.scrollTop; | ||
} | ||
return { | ||
startIndex, | ||
endIndex, | ||
itemCount, | ||
itemSizeCache, | ||
itemPositionCache, | ||
itemsVisible: visibleItems, | ||
itemsStyles, | ||
scrollerStyles, | ||
scrollerScrollTop: scrollTop, | ||
scrollerRef, | ||
wrapperRef, | ||
itemsRef, | ||
itemRefs, | ||
onUpdateItemSize, | ||
}; | ||
}, | ||
}); | ||
</script> | ||
|
||
<template> | ||
<div class="recycle-scroller-wrapper" ref="wrapperRef"> | ||
<div class="recycle-scroller" :style="scrollerStyles" ref="scrollerRef"> | ||
<div class="recycle-scroller-items-wrapper" :style="itemsStyles" ref="itemsRef"> | ||
<div | ||
v-for="item in itemsVisible" | ||
:key="item[itemKey]" | ||
class="recycle-scroller-item" | ||
:ref="(element) => (itemRefs[item[itemKey]] = element)" | ||
> | ||
<slot :item="item" :updateItemSize="onUpdateItemSize" /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</template> | ||
|
||
<style lang="scss"> | ||
.recycle-scroller-wrapper { | ||
height: 100%; | ||
width: 100%; | ||
overflow: auto; | ||
flex: 1 1 auto; | ||
} | ||
.recycle-scroller { | ||
width: 100%; | ||
display: block; | ||
position: relative; | ||
} | ||
.recycle-scroller-items-wrapper { | ||
position: absolute; | ||
width: 100%; | ||
} | ||
.recycle-scroller-item { | ||
display: flex; | ||
position: relative; | ||
width: 100%; | ||
} | ||
</style> |
3 changes: 3 additions & 0 deletions
3
packages/design-system/src/components/N8nRecycleScroller/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import N8nRecycleScroller from './RecycleScroller.vue'; | ||
|
||
export default N8nRecycleScroller; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.