fix(Slack Node): Enable pagination for RLC - listChannels (#21434)

This commit is contained in:
yehorkardash 2025-11-10 14:11:00 +00:00 committed by GitHub
parent 9299a7ee00
commit bd04340f4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 246 additions and 9 deletions

View File

@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render';
import { fireEvent, screen } from '@testing-library/vue';
import { fireEvent, screen, waitFor } from '@testing-library/vue';
import { vi } from 'vitest';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
@ -251,5 +251,208 @@ describe('ResourceLocatorDropdown', () => {
// Should not emit loadMore when hasMore is false
expect(wrapper.emitted().loadMore).toBeUndefined();
});
describe('auto-loading when list is not scrollable', () => {
it('should emit loadMore when list is not scrollable but hasMore is true', async () => {
vi.useFakeTimers();
const fewResources: IResourceLocatorResultExpanded[] = [
{
name: 'Workflow 1',
value: 'workflow-1',
url: '/workflow/workflow-1',
},
{
name: 'Workflow 2',
value: 'workflow-2',
url: '/workflow/workflow-2',
},
];
const props = {
show: true,
resources: fewResources,
hasMore: false,
loading: false,
filter: '',
};
const wrapper = renderComponent({ props });
const resultsContainer = await waitFor(
() =>
wrapper.container
.querySelector('[data-test-id="resource-locator-dropdown"]')
?.querySelector('[class*="container"]') as HTMLDivElement,
);
Object.defineProperty(resultsContainer, 'scrollHeight', {
value: 100,
writable: true,
});
Object.defineProperty(resultsContainer, 'clientHeight', {
value: 100,
writable: true,
});
await wrapper.rerender({ ...props, hasMore: true });
await vi.advanceTimersByTimeAsync(500);
expect(wrapper.emitted().loadMore).toBeDefined();
expect(wrapper.emitted().loadMore).toHaveLength(1);
vi.useRealTimers();
});
it('should not emit loadMore when list is scrollable even if hasMore is true', async () => {
vi.useFakeTimers();
const fewResources: IResourceLocatorResultExpanded[] = [
{
name: 'Workflow 1',
value: 'workflow-1',
url: '/workflow/workflow-1',
},
{
name: 'Workflow 2',
value: 'workflow-2',
url: '/workflow/workflow-2',
},
];
const props = {
show: true,
resources: fewResources,
hasMore: false,
loading: false,
filter: '',
};
const wrapper = renderComponent({ props });
const resultsContainer = await waitFor(
() =>
wrapper.container
.querySelector('[data-test-id="resource-locator-dropdown"]')
?.querySelector('[class*="container"]') as HTMLDivElement,
);
Object.defineProperty(resultsContainer, 'scrollHeight', {
value: 150,
writable: true,
});
Object.defineProperty(resultsContainer, 'clientHeight', {
value: 100,
writable: true,
});
await wrapper.rerender({ ...props, hasMore: true });
await vi.advanceTimersByTimeAsync(500);
expect(wrapper.emitted().loadMore).toBeUndefined();
vi.useRealTimers();
});
it('should not emit loadMore when filter is present', async () => {
vi.useFakeTimers();
const fewResources: IResourceLocatorResultExpanded[] = [
{
name: 'Workflow 1',
value: 'workflow-1',
url: '/workflow/workflow-1',
},
{
name: 'Workflow 2',
value: 'workflow-2',
url: '/workflow/workflow-2',
},
];
const props = {
show: true,
resources: fewResources,
hasMore: false,
loading: false,
filter: 'search',
};
const wrapper = renderComponent({ props });
const resultsContainer = await waitFor(
() =>
wrapper.container
.querySelector('[data-test-id="resource-locator-dropdown"]')
?.querySelector('[class*="container"]') as HTMLDivElement,
);
Object.defineProperty(resultsContainer, 'scrollHeight', {
value: 100,
writable: true,
});
Object.defineProperty(resultsContainer, 'clientHeight', {
value: 100,
writable: true,
});
await wrapper.rerender({ ...props, hasMore: true });
await vi.advanceTimersByTimeAsync(500);
expect(wrapper.emitted().loadMore).toBeUndefined();
vi.useRealTimers();
});
it('should not emit loadMore when loading', async () => {
vi.useFakeTimers();
const fewResources: IResourceLocatorResultExpanded[] = [
{
name: 'Workflow 1',
value: 'workflow-1',
url: '/workflow/workflow-1',
},
{
name: 'Workflow 2',
value: 'workflow-2',
url: '/workflow/workflow-2',
},
];
const props = {
show: true,
resources: fewResources,
hasMore: false,
loading: true,
};
const wrapper = renderComponent({ props });
const resultsContainer = await waitFor(
() =>
wrapper.container
.querySelector('[data-test-id="resource-locator-dropdown"]')
?.querySelector('[class*="container"]') as HTMLDivElement,
);
Object.defineProperty(resultsContainer, 'scrollHeight', {
value: 100,
writable: true,
});
Object.defineProperty(resultsContainer, 'clientHeight', {
value: 100,
writable: true,
});
await wrapper.rerender({ ...props, hasMore: true });
await vi.advanceTimersByTimeAsync(500);
expect(wrapper.emitted().loadMore).toBeUndefined();
vi.useRealTimers();
});
});
});
});

View File

@ -1,12 +1,13 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { useDebounce } from '@/app/composables/useDebounce';
import type { IResourceLocatorResultExpanded } from '@/Interface';
import { N8nBadge, N8nIcon, N8nInput, N8nLoading, N8nPopover } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useCssModule, watch } from 'vue';
import { N8nBadge, N8nIcon, N8nInput, N8nLoading, N8nPopover } from '@n8n/design-system';
const SEARCH_BAR_HEIGHT_PX = 40;
const SCROLL_MARGIN_PX = 10;
@ -47,6 +48,14 @@ const emit = defineEmits<{
addResourceClick: [];
}>();
const { debounce } = useDebounce();
const debouncedLoadMore = debounce(
() => {
emit('loadMore');
},
{ debounceTime: 500 },
);
const i18n = useI18n();
const $style = useCssModule();
@ -209,7 +218,7 @@ function onResultsEnd() {
resultsContainerRef.value.offsetHeight -
(resultsContainerRef.value.scrollHeight - resultsContainerRef.value.scrollTop);
if (diff > -SCROLL_MARGIN_PX && diff < SCROLL_MARGIN_PX) {
emit('loadMore');
debouncedLoadMore();
}
}
}
@ -219,6 +228,23 @@ function isWithinDropdown(element: HTMLElement) {
}
defineExpose({ isWithinDropdown });
const canLoadMore = computed(() => {
return props.hasMore && !props.loading && !props.filter;
});
watch(
canLoadMore,
(loadMore) => {
const isScrollable =
!!resultsContainerRef.value &&
resultsContainerRef.value?.scrollHeight > resultsContainerRef.value?.clientHeight;
if (loadMore && !isScrollable) {
debouncedLoadMore();
}
},
{ immediate: true },
);
</script>
<template>

View File

@ -183,16 +183,23 @@ export class SlackV2 implements INodeType {
async getChannels(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const qs = { types: 'public_channel,private_channel', limit: 1000 };
const channels = (await slackApiRequestAllItems.call(
const qs = {
types: 'public_channel,private_channel',
limit: 1000,
cursor: paginationToken,
};
const { channels, response_metadata } = (await slackApiRequest.call(
this,
'channels',
'GET',
'/conversations.list',
{},
qs,
)) as Array<{ id: string; name: string }>;
)) as {
channels: Array<{ id: string; name: string }>;
response_metadata?: { next_cursor?: string };
};
const results: INodeListSearchItems[] = channels
.map((c) => ({
name: c.name,
@ -209,7 +216,8 @@ export class SlackV2 implements INodeType {
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results };
const nextPaginationToken = response_metadata?.next_cursor || undefined;
return { results, paginationToken: nextPaginationToken };
},
async getUsers(this: ILoadOptionsFunctions, filter?: string): Promise<INodeListSearchResult> {
const users = (await slackApiRequestAllItems.call(