diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index dbb629b1ec..08a907c018 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.test.ts b/packages/frontend/editor-ui/src/components/ParameterInput.test.ts index 3ac3f9507b..03af6c4404 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.test.ts +++ b/packages/frontend/editor-ui/src/components/ParameterInput.test.ts @@ -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> { return { @@ -379,19 +381,19 @@ describe('ParameterInput.vue', () => { value: workflowId, }; - workflowsStore.allWorkflows = [ - { + workflowsStore.fetchWorkflowsPage.mockResolvedValue([ + mock({ 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(workflowBase)]; + workflowsStore.fetchWorkflowsPage.mockResolvedValue([mock(workflowBase)]); const { emitted, container, getByTestId } = renderComponent({ props: { diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts index 60c71e11f1..9f349d4133 100644 --- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts +++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.test.ts @@ -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(); }); diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue index da91187df8..a3b47631d5 100644 --- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue +++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocator.vue @@ -912,7 +912,7 @@ function removeOverride() { > { + 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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue index cfee80ff21..1243365339 100644 --- a/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue +++ b/packages/frontend/editor-ui/src/components/ResourceLocator/ResourceLocatorDropdown.vue @@ -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(), { @@ -60,14 +58,14 @@ const itemsRef = ref([]); const sortedResources = computed(() => { 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(() => { }, ); - 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 });