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.retry": "Retry",
"generic.error": "Something went wrong",
"generic.error.subworkflowCreationFailed": "Error creating sub-workflow",
"generic.settings": "Settings",
"generic.service": "the service",
"generic.star": "Star",

View File

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

View File

@@ -128,8 +128,9 @@ describe('ResourceLocator', () => {
await waitFor(() => {
expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled();
});
// Expect the items to be rendered
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);
// We should be getting one item for each result
TEST_ITEMS.forEach((item) => {
expect(getByText(item.name)).toBeInTheDocument();
@@ -286,7 +287,9 @@ describe('ResourceLocator', () => {
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) => {
expect(getByText(item.name)).toBeInTheDocument();
});

View File

@@ -912,7 +912,7 @@ function removeOverride() {
>
<ResourceLocatorDropdown
ref="dropdownRef"
:model-value="modelValue ? modelValue.value : ''"
:model-value="modelValue"
:show="resourceDropdownVisible"
:filterable="isSearchable"
: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 type { EventBus } 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';
const SEARCH_BAR_HEIGHT_PX = 40;
const SCROLL_MARGIN_PX = 10;
type Props = {
modelValue?: NodeParameterValue;
modelValue?: INodeParameterResourceLocator;
resources?: IResourceLocatorResultExpanded[];
show?: boolean;
filterable?: boolean;
@@ -21,10 +21,8 @@ type Props = {
errorView?: boolean;
filterRequired?: boolean;
width?: number;
allowNewResources?: { label?: string };
eventBus?: EventBus;
allowNewResources?: {
label?: string;
};
};
const props = withDefaults(defineProps<Props>(), {
@@ -60,14 +58,14 @@ const itemsRef = ref<HTMLDivElement[]>([]);
const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
const seen = new Set();
const { selected, notSelected } = props.resources.reduce(
const result = props.resources.reduce(
(acc, item: IResourceLocatorResultExpanded) => {
if (seen.has(item.value)) {
return acc;
}
seen.add(item.value);
if (props.modelValue && item.value === props.modelValue) {
if (props.modelValue && item.value === props.modelValue.value) {
acc.selected = item;
} else if (!item.isArchived) {
// Archived items are not shown in the list unless selected
@@ -82,11 +80,22 @@ const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
},
);
if (selected) {
return [selected, ...notSelected];
// Resources are paginated, so the currently selected one may not actually be
// 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(
@@ -105,12 +114,6 @@ watch(
},
);
watch(
() => props.loading,
() => {
setTimeout(() => onResultsEnd(), 0); // in case of filtering
},
);
onMounted(() => {
props.eventBus.on('keyDown', onKeyDown);
});
@@ -221,22 +224,26 @@ defineExpose({ isWithinDropdown });
<template>
<n8n-popover
placement="bottom"
:width="width"
:width="props.width"
:popper-class="$style.popover"
:visible="show"
:visible="props.show"
:teleported="false"
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>
</div>
<div v-if="filterable && !errorView" :class="$style.searchInput" @keydown="onKeyDown">
<div
v-if="props.filterable && !props.errorView"
:class="$style.searchInput"
@keydown="onKeyDown"
>
<N8nInput
ref="searchRef"
:model-value="filter"
:model-value="props.filter"
:clearable="true"
:placeholder="
allowNewResources.label
props.allowNewResources.label
? i18n.baseText('resourceLocator.placeholder.searchOrCreate')
: i18n.baseText('resourceLocator.placeholder.search')
"
@@ -248,23 +255,31 @@ defineExpose({ isWithinDropdown });
</template>
</N8nInput>
</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') }}
</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"
>
{{ i18n.baseText('resourceLocator.mode.list.noResults') }}
</div>
<div
v-else-if="!errorView"
v-else-if="!props.errorView"
ref="resultsContainerRef"
:class="$style.container"
@scroll="onResultsEnd"
>
<div
v-if="allowNewResources.label"
v-if="props.allowNewResources.label"
key="addResourceKey"
ref="itemsRef"
data-test-id="rlc-item-add-resource"
@@ -277,7 +292,7 @@ defineExpose({ isWithinDropdown });
@click="() => emit('addResourceClick')"
>
<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" />
</div>
</div>
@@ -287,7 +302,7 @@ defineExpose({ isWithinDropdown });
ref="itemsRef"
:class="{
[$style.resourceItem]: true,
[$style.selected]: result.value === modelValue,
[$style.selected]: result.value === props.modelValue?.value,
[$style.hovering]: hoverIndex === i + 1,
}"
data-test-id="rlc-item"
@@ -312,7 +327,7 @@ defineExpose({ isWithinDropdown });
/>
</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">
<N8nLoading :class="$style.loader" variant="p" :rows="1" />
</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 WorkflowSelectorParameterInput, {
type Props,
@@ -13,17 +19,25 @@ const { onDocumentVisible } = vi.hoisted(() => ({
const flushPromises = async () => await new Promise(setImmediate);
const mockToast = {
showError: vi.fn(),
};
vi.mock('@/composables/useDocumentVisibility', () => ({
useDocumentVisibility: () => ({ onDocumentVisible }),
}));
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(() => mockToast),
}));
vi.mock('vue-router', () => {
const push = vi.fn();
return {
useRouter: () => ({
push,
resolve: vi.fn().mockReturnValue({
href: '/projects/1/folders/1',
href: '/projects/1/workflows/1',
}),
}),
useRoute: () => ({}),
@@ -43,6 +57,17 @@ const workflowsStore = mockedStore(useWorkflowsStore);
describe('WorkflowSelectorParameterInput', () => {
beforeEach(() => {
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(() => {
@@ -65,13 +90,20 @@ describe('WorkflowSelectorParameterInput', () => {
default: '',
},
};
const { emitted } = renderComponent({ props });
const wrapper = renderComponent({ props });
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);
});
it('should update cached workflow when page is visible', async () => {
it('should update cached workflow when document becomes visible', async () => {
const props: Props = {
modelValue: {
__rl: true,
@@ -90,7 +122,12 @@ describe('WorkflowSelectorParameterInput', () => {
await flushPromises();
// 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);
workflowsStore.fetchWorkflow.mockReset();
@@ -98,7 +135,7 @@ describe('WorkflowSelectorParameterInput', () => {
const onDocumentVisibleCallback = onDocumentVisible.mock.lastCall?.[0];
await onDocumentVisibleCallback();
expect(emitted()['update:modelValue']?.[1]).toEqual([props.modelValue]);
expect(emitted()['update:modelValue']?.[1]).toEqual([expectedModelValue]);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
});
@@ -126,4 +163,103 @@ describe('WorkflowSelectorParameterInput', () => {
expect(getByTestId('parameter-issues')).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 type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
import { useToast } from '@/composables/useToast';
export interface Props {
modelValue: INodeParameterResourceLocator;
@@ -69,6 +70,7 @@ const i18n = useI18n();
const container = ref<HTMLDivElement>();
const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>();
const telemetry = useTelemetry();
const toast = useToast();
const width = ref(0);
const inputRef = ref<HTMLInputElement | undefined>();
@@ -88,15 +90,15 @@ const { onDocumentVisible } = useDocumentVisibility();
const {
hasMoreWorkflowsToLoad,
isLoadingResources,
filteredResources,
searchFilter,
onSearchFilter,
getWorkflowName,
applyDefaultExecuteWorkflowNodeName,
populateNextWorkflowsPage,
setWorkflowsResources,
reloadWorkflows,
workflowDbToResourceMapper,
getWorkflowUrl,
workflowsResources,
} = useWorkflowResourcesLocator(router);
const currentProjectName = computed(() => {
@@ -161,6 +163,7 @@ function onInputChange(workflowId: NodeParameterValue): void {
__rl: true,
value: workflowId,
mode: selectedMode.value,
cachedResultUrl: getWorkflowUrl(workflowId),
};
if (isListMode.value) {
const resource = workflowsStore.getWorkflowById(workflowId);
@@ -262,34 +265,44 @@ onClickOutside(dropdown, () => {
});
const onAddResourceClicked = async () => {
const projectId = projectStore.currentProjectId;
const sampleWorkflow = props.sampleWorkflow;
const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow';
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
(w) => w.name && new RegExp(workflowName).test(w.name),
);
try {
const projectId = projectStore.currentProjectId;
const sampleWorkflow = props.sampleWorkflow;
const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow';
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
(w) => w.name && new RegExp(workflowName).test(w.name),
);
const workflow: WorkflowDataCreate = {
...sampleWorkflow,
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
};
if (projectId) {
workflow.projectId = projectId;
const workflow: WorkflowDataCreate = {
...sampleWorkflow,
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
};
if (projectId) {
workflow.projectId = projectId;
}
telemetry.track('User clicked create new sub-workflow button', {});
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
const { href } = router.resolve({
name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
});
workflowsResources.value.push(workflowDbToResourceMapper(newWorkflow));
emit('update:modelValue', {
__rl: true,
value: newWorkflow.id,
mode: selectedMode.value,
cachedResultName: newWorkflow.name,
cachedResultUrl: getWorkflowUrl(newWorkflow.id),
});
hideDropdown();
window.open(href, '_blank');
emit('workflowCreated', newWorkflow.id);
} catch (error) {
toast.showError(error, i18n.baseText('generic.error.subworkflowCreationFailed'));
}
telemetry.track('User clicked create new sub-workflow button', {});
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
const { href } = router.resolve({
name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
});
await reloadWorkflows();
onInputChange(newWorkflow.id);
hideDropdown();
window.open(href, '_blank');
emit('workflowCreated', newWorkflow.id);
};
</script>
<template>
@@ -303,7 +316,7 @@ const onAddResourceClicked = async () => {
:show="isDropdownVisible"
:filterable="true"
:filter-required="false"
:resources="filteredResources"
:resources="workflowsResources"
:loading="isLoadingResources"
:filter="searchFilter"
:has-more="hasMoreWorkflowsToLoad"
@@ -313,7 +326,7 @@ const onAddResourceClicked = async () => {
}"
:width="width"
:event-bus="eventBus"
:model-value="modelValue.value"
:model-value="modelValue"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@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 { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
import { useWorkflowsStore } from '@/stores/workflows.store';
@@ -18,7 +22,9 @@ describe('useWorkflowResourcesLocator', () => {
let ndvStoreMock: MockedStore<typeof useNDVStore>;
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(() => {
vi.clearAllMocks();
@@ -108,4 +114,218 @@ describe('useWorkflowResourcesLocator', () => {
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 { useWorkflowsStore } from '@/stores/workflows.store';
import sortBy from 'lodash/sortBy';
import type { Router } from 'vue-router';
import { VIEWS } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import type { NodeParameterValue } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
@@ -14,71 +13,30 @@ export function useWorkflowResourcesLocator(router: Router) {
const ndvStore = useNDVStore();
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 searchFilter = ref('');
const currentPage = ref(0);
const PAGE_SIZE = 40;
const totalCount = ref(0);
const sortedWorkflows = computed(() =>
sortBy(workflowsStore.allWorkflows, (workflow) =>
new Date(workflow.updatedAt).valueOf(),
).reverse(),
);
const hasMoreWorkflowsToLoad = computed(() => totalCount.value > workflowsResources.value.length);
const hasMoreWorkflowsToLoad = computed(
() => workflowsStore.allWorkflows.length > workflowsResources.value.length,
);
function constructName(workflow: IWorkflowDb | WorkflowListResource) {
// 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() {
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;
return workflow.name;
}
function getWorkflowName(id: string): string {
const workflow = workflowsStore.getWorkflowById(id);
if (workflow) {
// Add the project name if it's not a personal project
if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
return `${workflow.homeProject.name}${workflow.name}`;
}
return workflow.name;
return constructName(workflow);
}
return id;
}
@@ -91,8 +49,51 @@ export function useWorkflowResourcesLocator(router: Router) {
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;
await populateNextWorkflowsPage(true);
}
function applyDefaultExecuteWorkflowNodeName(workflowId: NodeParameterValue) {
@@ -115,14 +116,13 @@ export function useWorkflowResourcesLocator(router: Router) {
workflowsResources,
isLoadingResources,
hasMoreWorkflowsToLoad,
filteredResources,
searchFilter,
reloadWorkflows,
getWorkflowUrl,
onSearchFilter,
getWorkflowName,
applyDefaultExecuteWorkflowNodeName,
populateNextWorkflowsPage,
setWorkflowsResources,
workflowDbToResourceMapper,
};
}