From 874c735d0af81c3c81cf82fb9bdf1232608d6400 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Fri, 27 Jan 2023 09:51:32 +0200 Subject: [PATCH] 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 --- cypress/e2e/8-http-request-node.cy.ts | 6 +- .../RecycleScroller.stories.ts | 59 ++++ .../N8nRecycleScroller/RecycleScroller.vue | 274 ++++++++++++++++++ .../components/N8nRecycleScroller/index.ts | 3 + .../src/components/N8nTags/Tags.vue | 8 +- .../src/plugins/n8nComponents.ts | 2 + packages/editor-ui/src/api/workflows.ts | 6 + .../editor-ui/src/components/WorkflowCard.vue | 4 + .../src/components/WorkflowShareModal.ee.vue | 37 ++- .../components/layouts/PageViewLayoutList.vue | 14 +- .../layouts/ResourcesListLayout.vue | 98 ++++--- packages/editor-ui/src/plugins/components.ts | 5 +- packages/editor-ui/src/stores/workflows.ts | 8 + .../editor-ui/src/views/CredentialsView.vue | 3 +- .../editor-ui/src/views/WorkflowsView.vue | 10 +- 15 files changed, 468 insertions(+), 69 deletions(-) create mode 100644 packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.stories.ts create mode 100644 packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue create mode 100644 packages/design-system/src/components/N8nRecycleScroller/index.ts diff --git a/cypress/e2e/8-http-request-node.cy.ts b/cypress/e2e/8-http-request-node.cy.ts index c8a4e1c9ec..eb4d93c171 100644 --- a/cypress/e2e/8-http-request-node.cy.ts +++ b/cypress/e2e/8-http-request-node.cy.ts @@ -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'); diff --git a/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.stories.ts b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.stories.ts new file mode 100644 index 0000000000..d1b1ed0e0b --- /dev/null +++ b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.stories.ts @@ -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: `
+ + + +
`, +}); + +export const RecycleScroller = Template.bind({}); diff --git a/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue new file mode 100644 index 0000000000..a9943b1201 --- /dev/null +++ b/packages/design-system/src/components/N8nRecycleScroller/RecycleScroller.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/packages/design-system/src/components/N8nRecycleScroller/index.ts b/packages/design-system/src/components/N8nRecycleScroller/index.ts new file mode 100644 index 0000000000..4ada18c3b2 --- /dev/null +++ b/packages/design-system/src/components/N8nRecycleScroller/index.ts @@ -0,0 +1,3 @@ +import N8nRecycleScroller from './RecycleScroller.vue'; + +export default N8nRecycleScroller; diff --git a/packages/design-system/src/components/N8nTags/Tags.vue b/packages/design-system/src/components/N8nTags/Tags.vue index 8b483d8dcb..4a104f284a 100644 --- a/packages/design-system/src/components/N8nTags/Tags.vue +++ b/packages/design-system/src/components/N8nTags/Tags.vue @@ -11,7 +11,7 @@ theme="text" underline size="small" - @click.stop.prevent="showAll = true" + @click.stop.prevent="onExpand" > {{ t('tags.showMore', hiddenTagsLength) }} @@ -67,6 +67,12 @@ export default mixins(Locale).extend({ return this.tags.length - this.truncateAt; }, }, + methods: { + onExpand() { + this.showAll = true; + this.$emit('expand', true); + }, + }, }); diff --git a/packages/design-system/src/plugins/n8nComponents.ts b/packages/design-system/src/plugins/n8nComponents.ts index dc5a03285f..c3bc377d7e 100644 --- a/packages/design-system/src/plugins/n8nComponents.ts +++ b/packages/design-system/src/plugins/n8nComponents.ts @@ -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); }, }; diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index f34eb77fdf..7e109b631a 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -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; diff --git a/packages/editor-ui/src/components/WorkflowCard.vue b/packages/editor-ui/src/components/WorkflowCard.vue index 983cbbc279..c4d76d7e06 100644 --- a/packages/editor-ui/src/components/WorkflowCard.vue +++ b/packages/editor-ui/src/components/WorkflowCard.vue @@ -28,6 +28,7 @@ :truncateAt="3" truncate @click="onClickTag" + @expand="onExpandTags" data-test-id="workflow-card-tags" /> @@ -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(); diff --git a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue index b5ecf5b8ce..3f5961b066 100644 --- a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue +++ b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue @@ -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>, 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; + }, }, }); diff --git a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue index 345b291cb1..f66ee0a2ad 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue @@ -1,7 +1,7 @@ - + + + -
- - {{ $locale.baseText(`${resourceKey}.filters.active`) }} - - {{ $locale.baseText(`${resourceKey}.filters.active.reset`) }} - - -
+ + {{ $locale.baseText(`${resourceKey}.noResults`) }} + + @@ -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>, 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 { diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 5c4d549135..567a26d1cc 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -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; diff --git a/packages/editor-ui/src/stores/workflows.ts b/packages/editor-ui/src/stores/workflows.ts index b156c70ab3..e32f1d6183 100644 --- a/packages/editor-ui/src/stores/workflows.ts +++ b/packages/editor-ui/src/stores/workflows.ts @@ -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 { + const rootStore = useRootStore(); + const workflow = await getWorkflow(rootStore.getRestApiContext, id); + this.addWorkflow(workflow); + return workflow; + }, + async getNewWorkflowData(name?: string): Promise { const workflowsEEStore = useWorkflowsEEStore(); diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 3ff980da74..901ca18ebe 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -6,11 +6,12 @@ :initialize="initialize" :filters="filters" :additional-filters-handler="onFilter" + :item-size="77" @click:add="addCredential" @update:filters="filters = $event" > -