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
This commit is contained in:
Alex Grozav 2023-01-27 09:51:32 +02:00 committed by GitHub
parent 8ce85e3759
commit 874c735d0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 468 additions and 69 deletions

View file

@ -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');

View file

@ -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({});

View file

@ -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>

View file

@ -0,0 +1,3 @@
import N8nRecycleScroller from './RecycleScroller.vue';
export default N8nRecycleScroller;

View file

@ -11,7 +11,7 @@
theme="text"
underline
size="small"
@click.stop.prevent="showAll = true"
@click.stop.prevent="onExpand"
>
{{ t('tags.showMore', hiddenTagsLength) }}
</n8n-link>
@ -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>

View file

@ -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) => {
@ -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);
},
};

View file

@ -10,6 +10,12 @@ export async function getNewWorkflow(context: IRestApiContext, name?: string) {
};
}
export async function getWorkflow(context: IRestApiContext, id: string, filter?: object) {
const sendData = filter ? { filter } : undefined;
return await makeRestApiRequest(context, 'GET', `/workflows/${id}`, sendData);
}
export async function getWorkflows(context: IRestApiContext, filter?: object) {
const sendData = filter ? { filter } : undefined;

View file

@ -28,6 +28,7 @@
:truncateAt="3"
truncate
@click="onClickTag"
@expand="onExpandTags"
data-test-id="workflow-card-tags"
/>
</span>
@ -189,6 +190,9 @@ export default mixins(showMessage, restApi).extend({
this.$emit('click:tag', tagId, event);
},
onExpandTags() {
this.$emit('expand:tags');
},
async onAction(action: string) {
if (action === WORKFLOW_LIST_ITEM_ACTIONS.OPEN) {
await this.onClick();

View file

@ -154,6 +154,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useWorkflowsEEStore } from '@/stores/workflows.ee';
import { ITelemetryTrackProperties } from 'n8n-workflow';
import { useUsageStore } from '@/stores/usage';
import { BaseTextKey } from '@/plugins/i18n';
export default mixins(showMessage).extend({
name: 'workflow-share-modal',
@ -175,7 +176,7 @@ export default mixins(showMessage).extend({
return {
WORKFLOW_SHARE_MODAL_KEY,
loading: false,
loading: true,
modalBus: new Vue(),
sharedWith: [...(workflow.sharedWith || [])] as Array<Partial<IUser>>,
EnterpriseEditionFeature,
@ -199,8 +200,9 @@ export default mixins(showMessage).extend({
modalTitle(): string {
return this.$locale.baseText(
this.isSharingEnabled
? this.uiStore.contextBasedTranslationKeys.workflows.sharing.title
: this.uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.title,
? (this.uiStore.contextBasedTranslationKeys.workflows.sharing.title as BaseTextKey)
: (this.uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable
.title as BaseTextKey),
{
interpolate: { name: this.workflow.name },
},
@ -380,7 +382,7 @@ export default mixins(showMessage).extend({
},
),
this.$locale.baseText('workflows.shareModal.list.delete.confirm.title', {
interpolate: { name: user.fullName },
interpolate: { name: user.fullName as string },
}),
null,
this.$locale.baseText('workflows.shareModal.list.delete.confirm.confirmButtonText'),
@ -437,18 +439,37 @@ export default mixins(showMessage).extend({
});
},
goToUpgrade() {
let linkUrl = this.$locale.baseText(this.uiStore.contextBasedTranslationKeys.upgradeLinkUrl);
let linkUrl = this.$locale.baseText(
this.uiStore.contextBasedTranslationKeys.upgradeLinkUrl as BaseTextKey,
);
if (linkUrl.includes('subscription')) {
linkUrl = `${this.usageStore.viewPlansUrl}&source=workflow_sharing`;
}
window.open(linkUrl, '_blank');
},
async initialize() {
if (this.isSharingEnabled) {
await this.loadUsers();
if (
this.workflow.id !== PLACEHOLDER_EMPTY_WORKFLOW_ID &&
!this.workflow.sharedWith?.length // Sharing info already loaded
) {
await this.workflowsStore.fetchWorkflow(this.workflow.id);
}
}
this.loading = false;
},
},
mounted() {
if (this.isSharingEnabled) {
this.loadUsers();
}
this.initialize();
},
watch: {
workflow(workflow) {
this.sharedWith = workflow.sharedWith;
},
},
});
</script>

View file

@ -1,7 +1,7 @@
<template>
<div :class="$style.wrapper">
<div :class="$style.list">
<div v-if="$slots.header">
<div v-if="$slots.header" :class="$style.header">
<slot name="header" />
</div>
<div :class="$style.body">
@ -21,12 +21,16 @@
.list {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
}
.body {
overflow: auto;
.header {
flex: 0 0 auto;
}
.body {
overflow: hidden;
flex: 1 1;
}
}
</style>

View file

@ -106,56 +106,56 @@
</div>
</div>
</div>
<slot name="callout"></slot>
<div v-show="hasFilters" class="mt-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText(`${resourceKey}.filters.active`) }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText(`${resourceKey}.filters.active.reset`) }}
</n8n-link>
</n8n-info-tip>
</div>
<div class="pb-xs" />
</template>
<slot name="callout"></slot>
<n8n-recycle-scroller
v-if="filteredAndSortedSubviewResources.length > 0"
data-test-id="resources-list"
:class="[$style.list, 'list-style-none']"
:items="filteredAndSortedSubviewResources"
:item-size="itemSize"
item-key="id"
>
<template #default="{ item, updateItemSize }">
<slot :data="item" :updateItemSize="updateItemSize" />
</template>
</n8n-recycle-scroller>
<div v-show="hasFilters" class="mt-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText(`${resourceKey}.filters.active`) }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText(`${resourceKey}.filters.active.reset`) }}
</n8n-link>
</n8n-info-tip>
</div>
<n8n-text color="text-base" size="medium" data-test-id="resources-list-empty" v-else>
{{ $locale.baseText(`${resourceKey}.noResults`) }}
<template v-if="shouldSwitchToAllSubview">
<span v-if="!filters.search">
({{ $locale.baseText(`${resourceKey}.noResults.switchToShared.preamble`) }}
<n8n-link @click="setOwnerSubview(false)">
{{ $locale.baseText(`${resourceKey}.noResults.switchToShared.link`) }} </n8n-link
>)
</span>
<div class="mt-xs mb-l">
<ul
:class="[$style.list, 'list-style-none']"
v-if="filteredAndSortedSubviewResources.length > 0"
data-test-id="resources-list"
>
<li
v-for="resource in filteredAndSortedSubviewResources"
:key="resource.id"
class="mb-2xs"
data-test-id="resources-list-item"
>
<slot :data="resource" />
</li>
</ul>
<n8n-text color="text-base" size="medium" data-test-id="resources-list-empty" v-else>
{{ $locale.baseText(`${resourceKey}.noResults`) }}
<template v-if="shouldSwitchToAllSubview">
<span v-if="!filters.search">
({{ $locale.baseText(`${resourceKey}.noResults.switchToShared.preamble`) }}
<n8n-link @click="setOwnerSubview(false)">{{
$locale.baseText(`${resourceKey}.noResults.switchToShared.link`)
}}</n8n-link
>)
</span>
<span v-else>
({{
$locale.baseText(`${resourceKey}.noResults.withSearch.switchToShared.preamble`)
}}
<n8n-link @click="setOwnerSubview(false)">{{
<span v-else>
({{
$locale.baseText(`${resourceKey}.noResults.withSearch.switchToShared.preamble`)
}}
<n8n-link @click="setOwnerSubview(false)">
{{
$locale.baseText(`${resourceKey}.noResults.withSearch.switchToShared.link`)
}}</n8n-link
>)
</span>
</template>
</n8n-text>
</div>
}} </n8n-link
>)
</span>
</template>
</n8n-text>
</page-view-layout-list>
</template>
</page-view-layout>
@ -217,6 +217,10 @@ export default mixins(showMessage, debounceHelper).extend({
type: Array,
default: (): IResource[] => [],
},
itemSize: {
type: Number,
default: 80,
},
initialize: {
type: Function as PropType<() => Promise<void>>,
default: () => () => Promise.resolve(),
@ -438,8 +442,8 @@ export default mixins(showMessage, debounceHelper).extend({
}
.list {
display: flex;
flex-direction: column;
//display: flex;
//flex-direction: column;
}
.sort-and-filter {

View file

@ -2,11 +2,10 @@
import Vue from 'vue';
import Fragment from 'vue-fragment';
import VueAgile from 'vue-agile';
import 'regenerator-runtime/runtime';
import VueAgile from 'vue-agile';
import ElementUI from 'element-ui';
import { Loading, MessageBox, Message, Notification } from 'element-ui';
import { designSystemComponents } from 'n8n-design-system';
@ -14,13 +13,13 @@ import { ElMessageBoxOptions } from 'element-ui/types/message-box';
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
Vue.use(Fragment.Plugin);
Vue.use(VueAgile);
Vue.use(ElementUI);
Vue.use(designSystemComponents);
Vue.component('enterprise-edition', EnterpriseEdition);
Vue.use(VueAgile);
Vue.use(Loading.directive);
Vue.prototype.$loading = Loading.service;

View file

@ -50,6 +50,7 @@ import {
getExecutionData,
getFinishedExecutions,
getNewWorkflow,
getWorkflow,
getWorkflows,
} from '@/api/workflows';
import { useUIStore } from './ui';
@ -258,6 +259,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
return workflows;
},
async fetchWorkflow(id: string): Promise<IWorkflowDb> {
const rootStore = useRootStore();
const workflow = await getWorkflow(rootStore.getRestApiContext, id);
this.addWorkflow(workflow);
return workflow;
},
async getNewWorkflowData(name?: string): Promise<INewWorkflowData> {
const workflowsEEStore = useWorkflowsEEStore();

View file

@ -6,11 +6,12 @@
:initialize="initialize"
:filters="filters"
:additional-filters-handler="onFilter"
:item-size="77"
@click:add="addCredential"
@update:filters="filters = $event"
>
<template #default="{ data }">
<credential-card :data="data" />
<credential-card data-test-id="resources-list-item" class="mb-2xs" :data="data" />
</template>
<template #filters="{ setKeyValue }">
<div class="mb-s">

View file

@ -22,8 +22,14 @@
</template>
</n8n-callout>
</template>
<template #default="{ data }">
<workflow-card :data="data" @click:tag="onClickTag" />
<template #default="{ data, updateItemSize }">
<workflow-card
data-test-id="resources-list-item"
class="mb-2xs"
:data="data"
@expand:tags="updateItemSize(data)"
@click:tag="onClickTag"
/>
</template>
<template #empty>
<div class="text-center mt-s">