mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): Optimize workflow selector search performance by implementing pagination (#19252)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user