fix(editor): Optimize workflow selector search performance by implementing pagination (#19252)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Martini
2025-09-10 17:17:10 +02:00
committed by GitHub
parent d3ee6512a9
commit 8f60b52533
10 changed files with 791 additions and 148 deletions

View File

@@ -74,6 +74,7 @@
"generic.refresh": "Refresh", "generic.refresh": "Refresh",
"generic.retry": "Retry", "generic.retry": "Retry",
"generic.error": "Something went wrong", "generic.error": "Something went wrong",
"generic.error.subworkflowCreationFailed": "Error creating sub-workflow",
"generic.settings": "Settings", "generic.settings": "Settings",
"generic.service": "the service", "generic.service": "the service",
"generic.star": "Star", "generic.star": "Star",

View File

@@ -13,6 +13,8 @@ import { createEventBus } from '@n8n/utils/event-bus';
import { createMockEnterpriseSettings } from '@/__tests__/mocks'; import { createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeParameterResourceLocator } from 'n8n-workflow'; import type { INodeParameterResourceLocator } from 'n8n-workflow';
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended';
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> { function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return { return {
@@ -379,19 +381,19 @@ describe('ParameterInput.vue', () => {
value: workflowId, value: workflowId,
}; };
workflowsStore.allWorkflows = [ workflowsStore.fetchWorkflowsPage.mockResolvedValue([
{ mock<WorkflowListResource>({
id: workflowId, id: workflowId,
name: 'Test', name: 'Test',
active: false, active: false,
isArchived: false, isArchived: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
nodes: [], // nodes: [],
connections: {}, // connections: {},
versionId: faker.string.uuid(), versionId: faker.string.uuid(),
}, }),
]; ]);
const { emitted, container, getByTestId, queryByTestId } = renderComponent({ const { emitted, container, getByTestId, queryByTestId } = renderComponent({
props: { props: {
@@ -432,19 +434,17 @@ describe('ParameterInput.vue', () => {
value: workflowId, value: workflowId,
}; };
workflowsStore.allWorkflows = [ const workflowBase = {
{
id: workflowId, id: workflowId,
name: 'Test', name: 'Test',
active: false, active: false,
isArchived: true, isArchived: true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
nodes: [],
connections: {},
versionId: faker.string.uuid(), versionId: faker.string.uuid(),
}, };
]; workflowsStore.allWorkflows = [mock<IWorkflowDb>(workflowBase)];
workflowsStore.fetchWorkflowsPage.mockResolvedValue([mock<WorkflowListResource>(workflowBase)]);
const { emitted, container, getByTestId } = renderComponent({ const { emitted, container, getByTestId } = renderComponent({
props: { props: {

View File

@@ -128,8 +128,9 @@ describe('ResourceLocator', () => {
await waitFor(() => { await waitFor(() => {
expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled(); expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled();
}); });
// Expect the items to be rendered // Expect the items to be rendered, including the cached value from
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length); // TEST_MODEL_VALUE
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length + 1);
// We should be getting one item for each result // We should be getting one item for each result
TEST_ITEMS.forEach((item) => { TEST_ITEMS.forEach((item) => {
expect(getByText(item.name)).toBeInTheDocument(); expect(getByText(item.name)).toBeInTheDocument();
@@ -286,7 +287,9 @@ describe('ResourceLocator', () => {
expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled(); expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled();
}); });
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length); // Expect the items to be rendered, including the cached value from
// TEST_MODEL_VALUE
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length + 1);
TEST_ITEMS.forEach((item) => { TEST_ITEMS.forEach((item) => {
expect(getByText(item.name)).toBeInTheDocument(); expect(getByText(item.name)).toBeInTheDocument();
}); });

View File

@@ -912,7 +912,7 @@ function removeOverride() {
> >
<ResourceLocatorDropdown <ResourceLocatorDropdown
ref="dropdownRef" ref="dropdownRef"
:model-value="modelValue ? modelValue.value : ''" :model-value="modelValue"
:show="resourceDropdownVisible" :show="resourceDropdownVisible"
:filterable="isSearchable" :filterable="isSearchable"
:filter-required="requiresSearchFilter" :filter-required="requiresSearchFilter"

View File

@@ -0,0 +1,255 @@
import { createComponentRenderer } from '@/__tests__/render';
import { fireEvent, screen } from '@testing-library/vue';
import { vi } from 'vitest';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import type { IResourceLocatorResultExpanded } from '@/Interface';
const mockResources: IResourceLocatorResultExpanded[] = [
{
name: 'Workflow 1',
value: 'workflow-1',
url: '/workflow/workflow-1',
},
{
name: 'Workflow 2',
value: 'workflow-2',
url: '/workflow/workflow-2',
},
];
const mockModelValue: INodeParameterResourceLocator = {
__rl: true,
value: 'workflow-1',
mode: 'list',
cachedResultName: 'Workflow 1',
cachedResultUrl: '/workflow/workflow-1',
};
const renderComponent = createComponentRenderer(ResourceLocatorDropdown, {
props: {
show: true,
resources: mockResources,
modelValue: mockModelValue,
},
});
describe('ResourceLocatorDropdown', () => {
beforeEach(() => {
vi.useRealTimers();
});
describe('cached result display', () => {
it('should show cached result when selected item is not in resources list', async () => {
vi.useFakeTimers();
const cachedModelValue: INodeParameterResourceLocator = {
__rl: true,
value: 'workflow-cached',
mode: 'list',
cachedResultName: 'Cached Workflow',
cachedResultUrl: '/workflow/workflow-cached',
};
renderComponent({
props: {
show: true,
resources: mockResources, // doesn't contain workflow-cached
modelValue: cachedModelValue,
},
});
expect(screen.getByText('Cached Workflow')).toBeInTheDocument();
// Find the cached workflow item and hover to show the link icon
const cachedItem = screen.getByText('Cached Workflow').closest('[data-test-id="rlc-item"]');
expect(cachedItem).toBeInTheDocument();
if (cachedItem) {
await fireEvent.mouseEnter(cachedItem);
// Fast-forward time by 250ms to trigger the hover timeout
await vi.advanceTimersByTimeAsync(250);
// Verify the external link icon is present after hover
const linkIcon = cachedItem.querySelector('svg[data-icon="external-link"]');
expect(linkIcon).toBeInTheDocument();
}
});
it('should prioritize actual resource over cached when both exist', () => {
const modelValue: INodeParameterResourceLocator = {
__rl: true,
value: 'workflow-1',
mode: 'list',
cachedResultName: 'Cached Name',
cachedResultUrl: '/cached-url',
};
renderComponent({
props: {
show: true,
resources: mockResources,
modelValue,
},
});
// Should show the actual resource name, not cached
expect(screen.getByText('Workflow 1')).toBeInTheDocument();
expect(screen.queryByText('Cached Name')).not.toBeInTheDocument();
});
});
describe('model value handling', () => {
it('should compare values correctly for selection highlighting', () => {
const modelValue: INodeParameterResourceLocator = {
__rl: true,
value: 'workflow-2',
mode: 'list',
};
renderComponent({
props: {
show: true,
resources: mockResources,
modelValue,
},
});
// Find the item containing "Workflow 2" and check that it's selected
const selectedItem = screen.getByText('Workflow 2').closest('[data-test-id="rlc-item"]');
expect(selectedItem).toHaveClass('selected');
// Find the item containing "Workflow 1" and check that it's not selected
const unselectedItem = screen.getByText('Workflow 1').closest('[data-test-id="rlc-item"]');
expect(unselectedItem).not.toHaveClass('selected');
});
});
describe('filtering behavior', () => {
it('should emit filter event when filter input changes', async () => {
const wrapper = renderComponent({
props: {
show: true,
resources: mockResources,
filterable: true,
},
});
const filterInput = screen.getByTestId('rlc-search');
await fireEvent.update(filterInput, 'test search');
expect(wrapper.emitted().filter).toEqual([['test search']]);
});
});
describe('resource selection', () => {
it('should emit update:modelValue with correct value when resource is clicked', async () => {
const wrapper = renderComponent({
props: {
show: true,
resources: mockResources,
},
});
const secondItem = screen.getByText('Workflow 2').closest('[data-test-id="rlc-item"]');
if (secondItem) {
await fireEvent.click(secondItem);
}
expect(wrapper.emitted()['update:modelValue']).toEqual([['workflow-2']]);
});
});
describe('pagination', () => {
it('should trigger onResultsEnd handler when scrolling to bottom with pagination', async () => {
// Generate multiple pages of mock data (enough to require scrolling)
const manyResources: IResourceLocatorResultExpanded[] = Array.from(
{ length: 50 },
(_, i) => ({
name: `Workflow ${i + 1}`,
value: `workflow-${i + 1}`,
url: `/workflow/workflow-${i + 1}`,
}),
);
const wrapper = renderComponent({
props: {
show: true,
resources: manyResources,
hasMore: true, // Indicates there are more items to load
loading: false,
},
});
const resultsContainer = screen
.getByTestId('resource-locator-dropdown')
.querySelector('[class*="container"]') as HTMLDivElement;
expect(resultsContainer).toBeInTheDocument();
// Mock scroll to bottom - simulate scrolling to the end
Object.defineProperty(resultsContainer, 'scrollTop', {
writable: true,
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
});
// Trigger scroll event
await fireEvent.scroll(resultsContainer);
// Verify loadMore event was emitted
expect(wrapper.emitted().loadMore).toHaveLength(1);
});
it('should not trigger loadMore when already loading', async () => {
const wrapper = renderComponent({
props: {
show: true,
resources: [],
hasMore: true,
loading: true, // Currently loading
},
});
const resultsContainer = screen
.getByTestId('resource-locator-dropdown')
.querySelector('[class*="container"]') as HTMLDivElement;
// Mock scroll to bottom
Object.defineProperty(resultsContainer, 'scrollTop', {
writable: true,
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
});
await fireEvent.scroll(resultsContainer);
// Should not emit loadMore when loading
expect(wrapper.emitted().loadMore).toBeUndefined();
});
it('should not trigger loadMore when no more items available', async () => {
const wrapper = renderComponent({
props: {
show: true,
resources: [],
hasMore: false, // No more items to load
loading: false,
},
});
const resultsContainer = screen
.getByTestId('resource-locator-dropdown')
.querySelector('[class*="container"]') as HTMLDivElement;
// Mock scroll to bottom
Object.defineProperty(resultsContainer, 'scrollTop', {
writable: true,
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
});
await fireEvent.scroll(resultsContainer);
// Should not emit loadMore when hasMore is false
expect(wrapper.emitted().loadMore).toBeUndefined();
});
});
});

View File

@@ -4,14 +4,14 @@ import type { IResourceLocatorResultExpanded } from '@/Interface';
import { N8nLoading } from '@n8n/design-system'; import { N8nLoading } from '@n8n/design-system';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import type { INodeParameterResourceLocator, NodeParameterValue } from 'n8n-workflow'; import type { INodeParameterResourceLocator } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useCssModule, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, useCssModule, watch } from 'vue';
const SEARCH_BAR_HEIGHT_PX = 40; const SEARCH_BAR_HEIGHT_PX = 40;
const SCROLL_MARGIN_PX = 10; const SCROLL_MARGIN_PX = 10;
type Props = { type Props = {
modelValue?: NodeParameterValue; modelValue?: INodeParameterResourceLocator;
resources?: IResourceLocatorResultExpanded[]; resources?: IResourceLocatorResultExpanded[];
show?: boolean; show?: boolean;
filterable?: boolean; filterable?: boolean;
@@ -21,10 +21,8 @@ type Props = {
errorView?: boolean; errorView?: boolean;
filterRequired?: boolean; filterRequired?: boolean;
width?: number; width?: number;
allowNewResources?: { label?: string };
eventBus?: EventBus; eventBus?: EventBus;
allowNewResources?: {
label?: string;
};
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -60,14 +58,14 @@ const itemsRef = ref<HTMLDivElement[]>([]);
const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => { const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
const seen = new Set(); const seen = new Set();
const { selected, notSelected } = props.resources.reduce( const result = props.resources.reduce(
(acc, item: IResourceLocatorResultExpanded) => { (acc, item: IResourceLocatorResultExpanded) => {
if (seen.has(item.value)) { if (seen.has(item.value)) {
return acc; return acc;
} }
seen.add(item.value); seen.add(item.value);
if (props.modelValue && item.value === props.modelValue) { if (props.modelValue && item.value === props.modelValue.value) {
acc.selected = item; acc.selected = item;
} else if (!item.isArchived) { } else if (!item.isArchived) {
// Archived items are not shown in the list unless selected // Archived items are not shown in the list unless selected
@@ -82,11 +80,22 @@ const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
}, },
); );
if (selected) { // Resources are paginated, so the currently selected one may not actually be
return [selected, ...notSelected]; // in the list.
// If that's the case we'll render the cached value.
if (result.selected === null && props.modelValue?.cachedResultName && props.modelValue.value) {
result.selected = {
name: props.modelValue.cachedResultName,
value: props.modelValue.value,
url: props.modelValue.cachedResultUrl,
};
} }
return notSelected; if (result.selected) {
return [result.selected, ...result.notSelected];
}
return result.notSelected;
}); });
watch( watch(
@@ -105,12 +114,6 @@ watch(
}, },
); );
watch(
() => props.loading,
() => {
setTimeout(() => onResultsEnd(), 0); // in case of filtering
},
);
onMounted(() => { onMounted(() => {
props.eventBus.on('keyDown', onKeyDown); props.eventBus.on('keyDown', onKeyDown);
}); });
@@ -221,22 +224,26 @@ defineExpose({ isWithinDropdown });
<template> <template>
<n8n-popover <n8n-popover
placement="bottom" placement="bottom"
:width="width" :width="props.width"
:popper-class="$style.popover" :popper-class="$style.popover"
:visible="show" :visible="props.show"
:teleported="false" :teleported="false"
data-test-id="resource-locator-dropdown" data-test-id="resource-locator-dropdown"
> >
<div v-if="errorView" :class="$style.messageContainer"> <div v-if="props.errorView" :class="$style.messageContainer">
<slot name="error"></slot> <slot name="error"></slot>
</div> </div>
<div v-if="filterable && !errorView" :class="$style.searchInput" @keydown="onKeyDown"> <div
v-if="props.filterable && !props.errorView"
:class="$style.searchInput"
@keydown="onKeyDown"
>
<N8nInput <N8nInput
ref="searchRef" ref="searchRef"
:model-value="filter" :model-value="props.filter"
:clearable="true" :clearable="true"
:placeholder=" :placeholder="
allowNewResources.label props.allowNewResources.label
? i18n.baseText('resourceLocator.placeholder.searchOrCreate') ? i18n.baseText('resourceLocator.placeholder.searchOrCreate')
: i18n.baseText('resourceLocator.placeholder.search') : i18n.baseText('resourceLocator.placeholder.search')
" "
@@ -248,23 +255,31 @@ defineExpose({ isWithinDropdown });
</template> </template>
</N8nInput> </N8nInput>
</div> </div>
<div v-if="filterRequired && !filter && !errorView && !loading" :class="$style.searchRequired"> <div
v-if="props.filterRequired && !props.filter && !props.errorView && !props.loading"
:class="$style.searchRequired"
>
{{ i18n.baseText('resourceLocator.mode.list.searchRequired') }} {{ i18n.baseText('resourceLocator.mode.list.searchRequired') }}
</div> </div>
<div <div
v-else-if="!errorView && !allowNewResources.label && sortedResources.length === 0 && !loading" v-else-if="
!props.errorView &&
!props.allowNewResources.label &&
sortedResources.length === 0 &&
!props.loading
"
:class="$style.messageContainer" :class="$style.messageContainer"
> >
{{ i18n.baseText('resourceLocator.mode.list.noResults') }} {{ i18n.baseText('resourceLocator.mode.list.noResults') }}
</div> </div>
<div <div
v-else-if="!errorView" v-else-if="!props.errorView"
ref="resultsContainerRef" ref="resultsContainerRef"
:class="$style.container" :class="$style.container"
@scroll="onResultsEnd" @scroll="onResultsEnd"
> >
<div <div
v-if="allowNewResources.label" v-if="props.allowNewResources.label"
key="addResourceKey" key="addResourceKey"
ref="itemsRef" ref="itemsRef"
data-test-id="rlc-item-add-resource" data-test-id="rlc-item-add-resource"
@@ -277,7 +292,7 @@ defineExpose({ isWithinDropdown });
@click="() => emit('addResourceClick')" @click="() => emit('addResourceClick')"
> >
<div :class="$style.resourceNameContainer"> <div :class="$style.resourceNameContainer">
<span :class="$style.addResourceText">{{ allowNewResources.label }}</span> <span :class="$style.addResourceText">{{ props.allowNewResources.label }}</span>
<n8n-icon :class="$style.addResourceIcon" icon="plus" /> <n8n-icon :class="$style.addResourceIcon" icon="plus" />
</div> </div>
</div> </div>
@@ -287,7 +302,7 @@ defineExpose({ isWithinDropdown });
ref="itemsRef" ref="itemsRef"
:class="{ :class="{
[$style.resourceItem]: true, [$style.resourceItem]: true,
[$style.selected]: result.value === modelValue, [$style.selected]: result.value === props.modelValue?.value,
[$style.hovering]: hoverIndex === i + 1, [$style.hovering]: hoverIndex === i + 1,
}" }"
data-test-id="rlc-item" data-test-id="rlc-item"
@@ -312,7 +327,7 @@ defineExpose({ isWithinDropdown });
/> />
</div> </div>
</div> </div>
<div v-if="loading && !errorView"> <div v-if="props.loading && !props.errorView">
<div v-for="i in 3" :key="i" :class="$style.loadingItem"> <div v-for="i in 3" :key="i" :class="$style.loadingItem">
<N8nLoading :class="$style.loader" variant="p" :rows="1" /> <N8nLoading :class="$style.loader" variant="p" :rows="1" />
</div> </div>

View File

@@ -1,3 +1,9 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import WorkflowSelectorParameterInput, { import WorkflowSelectorParameterInput, {
type Props, type Props,
@@ -13,17 +19,25 @@ const { onDocumentVisible } = vi.hoisted(() => ({
const flushPromises = async () => await new Promise(setImmediate); const flushPromises = async () => await new Promise(setImmediate);
const mockToast = {
showError: vi.fn(),
};
vi.mock('@/composables/useDocumentVisibility', () => ({ vi.mock('@/composables/useDocumentVisibility', () => ({
useDocumentVisibility: () => ({ onDocumentVisible }), useDocumentVisibility: () => ({ onDocumentVisible }),
})); }));
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(() => mockToast),
}));
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
const push = vi.fn(); const push = vi.fn();
return { return {
useRouter: () => ({ useRouter: () => ({
push, push,
resolve: vi.fn().mockReturnValue({ resolve: vi.fn().mockReturnValue({
href: '/projects/1/folders/1', href: '/projects/1/workflows/1',
}), }),
}), }),
useRoute: () => ({}), useRoute: () => ({}),
@@ -43,6 +57,17 @@ const workflowsStore = mockedStore(useWorkflowsStore);
describe('WorkflowSelectorParameterInput', () => { describe('WorkflowSelectorParameterInput', () => {
beforeEach(() => { beforeEach(() => {
createAppModals(); createAppModals();
// Mock store methods to prevent unhandled errors
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
workflowsStore.totalWorkflowCount = 0;
workflowsStore.getWorkflowById.mockReturnValue(null as any);
workflowsStore.fetchWorkflow.mockResolvedValue({} as any);
workflowsStore.createNewWorkflow.mockResolvedValue({
id: 'new-workflow-id',
name: 'New Workflow',
} as any);
workflowsStore.allWorkflows = [];
mockToast.showError.mockClear();
}); });
afterEach(() => { afterEach(() => {
@@ -65,13 +90,20 @@ describe('WorkflowSelectorParameterInput', () => {
default: '', default: '',
}, },
}; };
const { emitted } = renderComponent({ props }); const wrapper = renderComponent({ props });
await flushPromises(); await flushPromises();
expect(emitted()['update:modelValue']?.[0]).toEqual([props.modelValue]);
// The component adds cachedResultUrl to the model value
const expectedModelValue = {
...props.modelValue,
cachedResultUrl: '/projects/1/workflows/1',
};
expect(wrapper.emitted()['update:modelValue']?.[0]).toEqual([expectedModelValue]);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value); expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
}); });
it('should update cached workflow when page is visible', async () => { it('should update cached workflow when document becomes visible', async () => {
const props: Props = { const props: Props = {
modelValue: { modelValue: {
__rl: true, __rl: true,
@@ -90,7 +122,12 @@ describe('WorkflowSelectorParameterInput', () => {
await flushPromises(); await flushPromises();
// on mount // on mount
expect(emitted()['update:modelValue']?.[0]).toEqual([props.modelValue]); const expectedModelValue = {
...props.modelValue,
cachedResultUrl: '/projects/1/workflows/1',
};
expect(emitted()['update:modelValue']?.[0]).toEqual([expectedModelValue]);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value); expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
workflowsStore.fetchWorkflow.mockReset(); workflowsStore.fetchWorkflow.mockReset();
@@ -98,7 +135,7 @@ describe('WorkflowSelectorParameterInput', () => {
const onDocumentVisibleCallback = onDocumentVisible.mock.lastCall?.[0]; const onDocumentVisibleCallback = onDocumentVisible.mock.lastCall?.[0];
await onDocumentVisibleCallback(); await onDocumentVisibleCallback();
expect(emitted()['update:modelValue']?.[1]).toEqual([props.modelValue]); expect(emitted()['update:modelValue']?.[1]).toEqual([expectedModelValue]);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value); expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
}); });
@@ -126,4 +163,103 @@ describe('WorkflowSelectorParameterInput', () => {
expect(getByTestId('parameter-issues')).toBeInTheDocument(); expect(getByTestId('parameter-issues')).toBeInTheDocument();
expect(getByTestId('rlc-open-resource-link')).toBeInTheDocument(); expect(getByTestId('rlc-open-resource-link')).toBeInTheDocument();
}); });
describe('workflow caching behavior', () => {
it('should include cached URL in model value updates', async () => {
const props: Props = {
modelValue: {
__rl: true,
value: 'test-workflow',
mode: 'list',
},
path: '',
parameter: {
displayName: 'display-name',
type: 'workflowSelector',
name: 'name',
default: '',
},
};
const wrapper = renderComponent({ props });
await flushPromises();
const updateEvents = wrapper.emitted()['update:modelValue'] as any[];
const lastUpdate = updateEvents[updateEvents.length - 1][0];
expect(lastUpdate).toMatchObject({
__rl: true,
value: 'test-workflow',
mode: 'list',
cachedResultUrl: '/projects/1/workflows/1',
});
});
it('should handle workflow name caching from store', async () => {
const mockWorkflow = {
id: 'existing-workflow',
name: 'Existing Workflow',
};
workflowsStore.getWorkflowById.mockReturnValue(mockWorkflow as any);
const props: Props = {
modelValue: {
__rl: true,
value: 'existing-workflow',
mode: 'list',
},
path: '',
parameter: {
displayName: 'display-name',
type: 'workflowSelector',
name: 'name',
default: '',
},
};
renderComponent({ props });
await flushPromises();
// Verify workflow was fetched via fetchWorkflow, not getWorkflowById directly
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith('existing-workflow');
});
});
describe('error handling', () => {
it('should show toast when workflow creation fails', async () => {
// Make createNewWorkflow fail
const error = new Error('Failed to create workflow');
workflowsStore.createNewWorkflow.mockRejectedValue(error);
const props: Props = {
modelValue: {
__rl: true,
value: '',
mode: 'list',
},
path: '',
parameter: {
displayName: 'display-name',
type: 'workflowSelector',
name: 'name',
default: '',
},
};
const { getByTestId } = renderComponent({ props });
await flushPromises();
// Get the ResourceLocatorDropdown component to trigger the add resource click
const addResourceButton = getByTestId('rlc-item-add-resource');
expect(addResourceButton).toBeInTheDocument();
// Click the add resource button
addResourceButton.click();
await flushPromises();
// Verify the toast error was shown
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error creating sub-workflow');
});
});
}); });

View File

@@ -24,6 +24,7 @@ import { VIEWS } from '@/constants';
import { SAMPLE_SUBWORKFLOW_TRIGGER_ID, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows'; import { SAMPLE_SUBWORKFLOW_TRIGGER_ID, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows'; import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility'; import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
import { useToast } from '@/composables/useToast';
export interface Props { export interface Props {
modelValue: INodeParameterResourceLocator; modelValue: INodeParameterResourceLocator;
@@ -69,6 +70,7 @@ const i18n = useI18n();
const container = ref<HTMLDivElement>(); const container = ref<HTMLDivElement>();
const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>(); const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const toast = useToast();
const width = ref(0); const width = ref(0);
const inputRef = ref<HTMLInputElement | undefined>(); const inputRef = ref<HTMLInputElement | undefined>();
@@ -88,15 +90,15 @@ const { onDocumentVisible } = useDocumentVisibility();
const { const {
hasMoreWorkflowsToLoad, hasMoreWorkflowsToLoad,
isLoadingResources, isLoadingResources,
filteredResources,
searchFilter, searchFilter,
onSearchFilter, onSearchFilter,
getWorkflowName, getWorkflowName,
applyDefaultExecuteWorkflowNodeName, applyDefaultExecuteWorkflowNodeName,
populateNextWorkflowsPage, populateNextWorkflowsPage,
setWorkflowsResources, setWorkflowsResources,
reloadWorkflows, workflowDbToResourceMapper,
getWorkflowUrl, getWorkflowUrl,
workflowsResources,
} = useWorkflowResourcesLocator(router); } = useWorkflowResourcesLocator(router);
const currentProjectName = computed(() => { const currentProjectName = computed(() => {
@@ -161,6 +163,7 @@ function onInputChange(workflowId: NodeParameterValue): void {
__rl: true, __rl: true,
value: workflowId, value: workflowId,
mode: selectedMode.value, mode: selectedMode.value,
cachedResultUrl: getWorkflowUrl(workflowId),
}; };
if (isListMode.value) { if (isListMode.value) {
const resource = workflowsStore.getWorkflowById(workflowId); const resource = workflowsStore.getWorkflowById(workflowId);
@@ -262,6 +265,7 @@ onClickOutside(dropdown, () => {
}); });
const onAddResourceClicked = async () => { const onAddResourceClicked = async () => {
try {
const projectId = projectStore.currentProjectId; const projectId = projectStore.currentProjectId;
const sampleWorkflow = props.sampleWorkflow; const sampleWorkflow = props.sampleWorkflow;
const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow'; const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow';
@@ -283,13 +287,22 @@ const onAddResourceClicked = async () => {
name: VIEWS.WORKFLOW, name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID }, params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
}); });
await reloadWorkflows(); workflowsResources.value.push(workflowDbToResourceMapper(newWorkflow));
onInputChange(newWorkflow.id); emit('update:modelValue', {
__rl: true,
value: newWorkflow.id,
mode: selectedMode.value,
cachedResultName: newWorkflow.name,
cachedResultUrl: getWorkflowUrl(newWorkflow.id),
});
hideDropdown(); hideDropdown();
window.open(href, '_blank'); window.open(href, '_blank');
emit('workflowCreated', newWorkflow.id); emit('workflowCreated', newWorkflow.id);
} catch (error) {
toast.showError(error, i18n.baseText('generic.error.subworkflowCreationFailed'));
}
}; };
</script> </script>
<template> <template>
@@ -303,7 +316,7 @@ const onAddResourceClicked = async () => {
:show="isDropdownVisible" :show="isDropdownVisible"
:filterable="true" :filterable="true"
:filter-required="false" :filter-required="false"
:resources="filteredResources" :resources="workflowsResources"
:loading="isLoadingResources" :loading="isLoadingResources"
:filter="searchFilter" :filter="searchFilter"
:has-more="hasMoreWorkflowsToLoad" :has-more="hasMoreWorkflowsToLoad"
@@ -313,7 +326,7 @@ const onAddResourceClicked = async () => {
}" }"
:width="width" :width="width"
:event-bus="eventBus" :event-bus="eventBus"
:model-value="modelValue.value" :model-value="modelValue"
@update:model-value="onListItemSelected" @update:model-value="onListItemSelected"
@filter="onSearchFilter" @filter="onSearchFilter"
@load-more="populateNextWorkflowsPage" @load-more="populateNextWorkflowsPage"

View File

@@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@@ -18,7 +22,9 @@ describe('useWorkflowResourcesLocator', () => {
let ndvStoreMock: MockedStore<typeof useNDVStore>; let ndvStoreMock: MockedStore<typeof useNDVStore>;
const renameNodeMock = vi.fn(); const renameNodeMock = vi.fn();
const routerMock = { resolve: vi.fn() } as unknown as Router; const routerMock = {
resolve: vi.fn().mockReturnValue({ href: '/workflow/test' }),
} as unknown as Router;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -108,4 +114,218 @@ describe('useWorkflowResourcesLocator', () => {
expect(renameNodeMock).not.toHaveBeenCalled(); expect(renameNodeMock).not.toHaveBeenCalled();
}); });
}); });
describe('pagination functionality', () => {
it('should initialize with correct default state', () => {
const { hasMoreWorkflowsToLoad, workflowsResources } =
useWorkflowResourcesLocator(routerMock);
expect(workflowsResources.value).toEqual([]);
expect(hasMoreWorkflowsToLoad.value).toBe(false);
});
it('should populate next workflows page correctly', async () => {
const mockWorkflows = [
{ id: '1', name: 'Workflow 1' },
{ id: '2', name: 'Workflow 2' },
] as any;
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue(mockWorkflows);
workflowsStoreMock.totalWorkflowCount = 100;
const { populateNextWorkflowsPage, workflowsResources, hasMoreWorkflowsToLoad } =
useWorkflowResourcesLocator(routerMock);
await populateNextWorkflowsPage();
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
undefined, // projectId
1, // page
40, // pageSize
'updatedAt:desc', // sort
undefined, // filter
);
expect(workflowsResources.value).toEqual([
{ name: 'Workflow 1', value: '1', url: expect.any(String) as string, isArchived: false },
{ name: 'Workflow 2', value: '2', url: expect.any(String) as string, isArchived: false },
]);
expect(hasMoreWorkflowsToLoad.value).toBe(true);
});
it('should handle search filtering with pagination reset', async () => {
const mockFilteredWorkflows = [{ id: '3', name: 'Filtered Workflow' }] as any;
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue(mockFilteredWorkflows);
const { onSearchFilter, workflowsResources } = useWorkflowResourcesLocator(routerMock);
// Pre-populate some workflows
workflowsResources.value = [
{ name: 'Old Workflow', value: 'old', url: '/old', isArchived: false },
];
await onSearchFilter('test search');
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
undefined,
1,
40,
'updatedAt:desc',
{ name: 'test search' },
);
// Should reset workflows array and populate with filtered results
expect(workflowsResources.value).toEqual([
{
name: 'Filtered Workflow',
value: '3',
url: expect.any(String) as string,
isArchived: false,
},
]);
});
it('should calculate hasMore correctly based on total count', async () => {
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue([
{ id: '1', name: 'Workflow 1' },
] as any);
workflowsStoreMock.totalWorkflowCount = 1; // Only 1 total, so no more after first load
const { populateNextWorkflowsPage, hasMoreWorkflowsToLoad } =
useWorkflowResourcesLocator(routerMock);
await populateNextWorkflowsPage();
expect(hasMoreWorkflowsToLoad.value).toBe(false);
});
it('should handle multiple page loads correctly', async () => {
const firstPageWorkflows = [
{ id: '1', name: 'Workflow 1' },
{ id: '2', name: 'Workflow 2' },
] as any;
const secondPageWorkflows = [
{ id: '3', name: 'Workflow 3' },
{ id: '4', name: 'Workflow 4' },
] as any;
workflowsStoreMock.fetchWorkflowsPage
.mockResolvedValueOnce(firstPageWorkflows)
.mockResolvedValueOnce(secondPageWorkflows);
workflowsStoreMock.totalWorkflowCount = 100;
const { populateNextWorkflowsPage, workflowsResources } =
useWorkflowResourcesLocator(routerMock);
// Load first page
await populateNextWorkflowsPage();
expect(workflowsResources.value).toHaveLength(2);
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
undefined,
1,
40,
'updatedAt:desc',
undefined,
);
// Load second page
await populateNextWorkflowsPage();
expect(workflowsResources.value).toHaveLength(4);
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
undefined,
2,
40,
'updatedAt:desc',
undefined,
);
// Verify workflows from both pages are present
expect(workflowsResources.value.map((w) => w.name)).toEqual([
'Workflow 1',
'Workflow 2',
'Workflow 3',
'Workflow 4',
]);
});
});
describe('workflowDbToResourceMapper', () => {
it('should map WorkflowListResource correctly', () => {
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-id' });
const { workflowDbToResourceMapper } = useWorkflowResourcesLocator(routerMock);
const workflow = {
id: 'test-id',
name: 'Test Workflow',
} as any;
const result = workflowDbToResourceMapper(workflow);
expect(result).toEqual({
name: 'Test Workflow',
value: 'test-id',
url: '/workflow/test-id',
isArchived: false,
});
});
it('should map IWorkflowDb with archived status correctly', () => {
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-id' });
const { workflowDbToResourceMapper } = useWorkflowResourcesLocator(routerMock);
const workflow: IWorkflowDb = {
id: 'test-id',
name: 'Archived Workflow',
isArchived: true,
} as IWorkflowDb;
const result = workflowDbToResourceMapper(workflow);
expect(result).toEqual({
name: 'Archived Workflow',
value: 'test-id',
url: '/workflow/test-id',
isArchived: true,
});
});
});
describe('utility functions', () => {
it('should generate correct workflow URL', () => {
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-workflow-id' });
const { getWorkflowUrl } = useWorkflowResourcesLocator(routerMock);
const url = getWorkflowUrl('test-workflow-id');
expect(routerMock.resolve as any).toHaveBeenCalledWith({
name: 'NodeViewExisting',
params: { name: 'test-workflow-id' },
});
expect(url).toBe('/workflow/test-workflow-id');
});
it('should get workflow name from store', () => {
const mockWorkflow = { id: 'test-id', name: 'Test Name' } as IWorkflowDb;
workflowsStoreMock.getWorkflowById.mockReturnValue(mockWorkflow);
const { getWorkflowName } = useWorkflowResourcesLocator(routerMock);
const name = getWorkflowName('test-id');
expect(name).toBe('Test Name');
expect(workflowsStoreMock.getWorkflowById).toHaveBeenCalledWith('test-id');
});
it('should return workflow ID when workflow not found in store', () => {
workflowsStoreMock.getWorkflowById.mockReturnValue(null as any);
const { getWorkflowName } = useWorkflowResourcesLocator(routerMock);
const name = getWorkflowName('missing-id');
expect(name).toBe('missing-id');
});
});
}); });

View File

@@ -1,10 +1,9 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import sortBy from 'lodash/sortBy';
import type { Router } from 'vue-router'; import type { Router } from 'vue-router';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import type { NodeParameterValue } from 'n8n-workflow'; import type { NodeParameterValue } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useCanvasOperations } from '@/composables/useCanvasOperations';
@@ -14,71 +13,30 @@ export function useWorkflowResourcesLocator(router: Router) {
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const { renameNode } = useCanvasOperations(); const { renameNode } = useCanvasOperations();
const workflowsResources = ref<Array<{ name: string; value: string; url: string }>>([]); const workflowsResources = ref<
Array<{ name: string; value: string; url: string; isArchived: boolean }>
>([]);
const isLoadingResources = ref(true); const isLoadingResources = ref(true);
const searchFilter = ref(''); const searchFilter = ref('');
const currentPage = ref(0);
const PAGE_SIZE = 40; const PAGE_SIZE = 40;
const totalCount = ref(0);
const sortedWorkflows = computed(() => const hasMoreWorkflowsToLoad = computed(() => totalCount.value > workflowsResources.value.length);
sortBy(workflowsStore.allWorkflows, (workflow) =>
new Date(workflow.updatedAt).valueOf(),
).reverse(),
);
const hasMoreWorkflowsToLoad = computed( function constructName(workflow: IWorkflowDb | WorkflowListResource) {
() => workflowsStore.allWorkflows.length > workflowsResources.value.length, // Add the project name if it's not a personal project
); if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
return `${workflow.homeProject.name}${workflow.name}`;
const filteredResources = computed(() => {
return workflowsStore.allWorkflows
.filter((resource) => resource.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
.map(workflowDbToResourceMapper);
});
async function populateNextWorkflowsPage() {
await workflowsStore.fetchAllWorkflows();
const nextPage = sortedWorkflows.value.slice(
workflowsResources.value.length,
workflowsResources.value.length + PAGE_SIZE,
);
workflowsResources.value.push(...nextPage.map(workflowDbToResourceMapper));
} }
async function setWorkflowsResources() { return workflow.name;
isLoadingResources.value = true;
await populateNextWorkflowsPage();
isLoadingResources.value = false;
}
async function reloadWorkflows() {
isLoadingResources.value = true;
await workflowsStore.fetchAllWorkflows();
isLoadingResources.value = false;
}
function workflowDbToResourceMapper(workflow: IWorkflowDb) {
return {
name: getWorkflowName(workflow.id),
value: workflow.id,
url: getWorkflowUrl(workflow.id),
isArchived: workflow.isArchived,
};
}
function getWorkflowUrl(workflowId: string) {
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
return href;
} }
function getWorkflowName(id: string): string { function getWorkflowName(id: string): string {
const workflow = workflowsStore.getWorkflowById(id); const workflow = workflowsStore.getWorkflowById(id);
if (workflow) { if (workflow) {
// Add the project name if it's not a personal project return constructName(workflow);
if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
return `${workflow.homeProject.name}${workflow.name}`;
}
return workflow.name;
} }
return id; return id;
} }
@@ -91,8 +49,51 @@ export function useWorkflowResourcesLocator(router: Router) {
return null; return null;
} }
function onSearchFilter(filter: string) { function getWorkflowUrl(workflowId: string) {
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
return href;
}
function workflowDbToResourceMapper(workflow: WorkflowListResource | IWorkflowDb) {
return {
name: constructName(workflow),
value: workflow.id,
url: getWorkflowUrl(workflow.id),
isArchived: 'isArchived' in workflow ? workflow.isArchived : false,
};
}
async function populateNextWorkflowsPage(reset = false) {
if (reset) {
currentPage.value = 0;
}
currentPage.value++;
const workflows = await workflowsStore.fetchWorkflowsPage(
undefined,
currentPage.value,
PAGE_SIZE,
'updatedAt:desc',
searchFilter.value ? { name: searchFilter.value } : undefined,
);
totalCount.value = workflowsStore.totalWorkflowCount;
if (reset) {
workflowsResources.value = workflows.map(workflowDbToResourceMapper);
} else {
workflowsResources.value.push(...workflows.map(workflowDbToResourceMapper));
}
}
async function setWorkflowsResources() {
isLoadingResources.value = true;
await populateNextWorkflowsPage();
isLoadingResources.value = false;
}
async function onSearchFilter(filter: string) {
searchFilter.value = filter; searchFilter.value = filter;
await populateNextWorkflowsPage(true);
} }
function applyDefaultExecuteWorkflowNodeName(workflowId: NodeParameterValue) { function applyDefaultExecuteWorkflowNodeName(workflowId: NodeParameterValue) {
@@ -115,14 +116,13 @@ export function useWorkflowResourcesLocator(router: Router) {
workflowsResources, workflowsResources,
isLoadingResources, isLoadingResources,
hasMoreWorkflowsToLoad, hasMoreWorkflowsToLoad,
filteredResources,
searchFilter, searchFilter,
reloadWorkflows,
getWorkflowUrl, getWorkflowUrl,
onSearchFilter, onSearchFilter,
getWorkflowName, getWorkflowName,
applyDefaultExecuteWorkflowNodeName, applyDefaultExecuteWorkflowNodeName,
populateNextWorkflowsPage, populateNextWorkflowsPage,
setWorkflowsResources, setWorkflowsResources,
workflowDbToResourceMapper,
}; };
} }