Skip to content

Commit

Permalink
feat: Improve workflow list performance using RecycleScroller and on-…
Browse files Browse the repository at this point in the history
…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
alexgrozav authored Jan 27, 2023
1 parent 8ce85e3 commit 874c735
Show file tree
Hide file tree
Showing 15 changed files with 470 additions and 71 deletions.
6 changes: 4 additions & 2 deletions cypress/e2e/8-http-request-node.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { WorkflowPage, WorkflowsPage, NDV } from '../pages';

const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const ndv = new NDV()
const ndv = new NDV();

describe('HTTP Request node', () => {
before(() => {
beforeEach(() => {
cy.resetAll();
cy.skipSetup();
});

it('should make a request with a URL and receive a response', () => {
cy.visit(workflowsPage.url);

workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('HTTP Request');
Expand Down
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({});
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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import N8nRecycleScroller from './RecycleScroller.vue';

export default N8nRecycleScroller;
8 changes: 7 additions & 1 deletion packages/design-system/src/components/N8nTags/Tags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
theme="text"
underline
size="small"
@click.stop.prevent="showAll = true"
@click.stop.prevent="onExpand"
>
{{ t('tags.showMore', hiddenTagsLength) }}
</n8n-link>
Expand Down Expand Up @@ -67,6 +67,12 @@ export default mixins(Locale).extend({
return this.tags.length - this.truncateAt;
},
},
methods: {
onExpand() {
this.showAll = true;
this.$emit('expand', true);
},
},
});
</script>

Expand Down
2 changes: 2 additions & 0 deletions packages/design-system/src/plugins/n8nComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import N8nUserInfo from '../components/N8nUserInfo';
import N8nUserSelect from '../components/N8nUserSelect';
import N8nUsersList from '../components/N8nUsersList';
import N8nResizeWrapper from '../components/N8nResizeWrapper';
import N8nRecycleScroller from '../components/N8nRecycleScroller';

export default {
install: (app: typeof Vue) => {
Expand Down Expand Up @@ -94,5 +95,6 @@ export default {
app.component('n8n-users-list', N8nUsersList);
app.component('n8n-user-select', N8nUserSelect);
app.component('n8n-resize-wrapper', N8nResizeWrapper);
app.component('n8n-recycle-scroller', N8nRecycleScroller);
},
};
Loading

0 comments on commit 874c735

Please sign in to comment.