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
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 workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage(); const workflowPage = new WorkflowPage();
const ndv = new NDV() const ndv = new NDV();
describe('HTTP Request node', () => { describe('HTTP Request node', () => {
before(() => { beforeEach(() => {
cy.resetAll(); cy.resetAll();
cy.skipSetup(); cy.skipSetup();
}); });
it('should make a request with a URL and receive a response', () => { it('should make a request with a URL and receive a response', () => {
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard(); workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('HTTP Request'); 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" theme="text"
underline underline
size="small" size="small"
@click.stop.prevent="showAll = true" @click.stop.prevent="onExpand"
> >
{{ t('tags.showMore', hiddenTagsLength) }} {{ t('tags.showMore', hiddenTagsLength) }}
</n8n-link> </n8n-link>
@@ -67,6 +67,12 @@ export default mixins(Locale).extend({
return this.tags.length - this.truncateAt; return this.tags.length - this.truncateAt;
}, },
}, },
methods: {
onExpand() {
this.showAll = true;
this.$emit('expand', true);
},
},
}); });
</script> </script>

View File

@@ -45,6 +45,7 @@ import N8nUserInfo from '../components/N8nUserInfo';
import N8nUserSelect from '../components/N8nUserSelect'; import N8nUserSelect from '../components/N8nUserSelect';
import N8nUsersList from '../components/N8nUsersList'; import N8nUsersList from '../components/N8nUsersList';
import N8nResizeWrapper from '../components/N8nResizeWrapper'; import N8nResizeWrapper from '../components/N8nResizeWrapper';
import N8nRecycleScroller from '../components/N8nRecycleScroller';
export default { export default {
install: (app: typeof Vue) => { install: (app: typeof Vue) => {
@@ -94,5 +95,6 @@ export default {
app.component('n8n-users-list', N8nUsersList); app.component('n8n-users-list', N8nUsersList);
app.component('n8n-user-select', N8nUserSelect); app.component('n8n-user-select', N8nUserSelect);
app.component('n8n-resize-wrapper', N8nResizeWrapper); 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) { export async function getWorkflows(context: IRestApiContext, filter?: object) {
const sendData = filter ? { filter } : undefined; const sendData = filter ? { filter } : undefined;

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,10 @@
import Vue from 'vue'; import Vue from 'vue';
import Fragment from 'vue-fragment'; import Fragment from 'vue-fragment';
import VueAgile from 'vue-agile';
import 'regenerator-runtime/runtime'; import 'regenerator-runtime/runtime';
import VueAgile from 'vue-agile';
import ElementUI from 'element-ui'; import ElementUI from 'element-ui';
import { Loading, MessageBox, Message, Notification } from 'element-ui'; import { Loading, MessageBox, Message, Notification } from 'element-ui';
import { designSystemComponents } from 'n8n-design-system'; 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'; import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
Vue.use(Fragment.Plugin); Vue.use(Fragment.Plugin);
Vue.use(VueAgile);
Vue.use(ElementUI); Vue.use(ElementUI);
Vue.use(designSystemComponents); Vue.use(designSystemComponents);
Vue.component('enterprise-edition', EnterpriseEdition); Vue.component('enterprise-edition', EnterpriseEdition);
Vue.use(VueAgile);
Vue.use(Loading.directive); Vue.use(Loading.directive);
Vue.prototype.$loading = Loading.service; Vue.prototype.$loading = Loading.service;

View File

@@ -50,6 +50,7 @@ import {
getExecutionData, getExecutionData,
getFinishedExecutions, getFinishedExecutions,
getNewWorkflow, getNewWorkflow,
getWorkflow,
getWorkflows, getWorkflows,
} from '@/api/workflows'; } from '@/api/workflows';
import { useUIStore } from './ui'; import { useUIStore } from './ui';
@@ -258,6 +259,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
return 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> { async getNewWorkflowData(name?: string): Promise<INewWorkflowData> {
const workflowsEEStore = useWorkflowsEEStore(); const workflowsEEStore = useWorkflowsEEStore();

View File

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

View File

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