From 73cc042ebc6e0a74465fa00d80311e7dcbe447ca Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Mon, 1 Sep 2025 15:29:08 +0200 Subject: [PATCH] feat(editor): Provide default ExecuteWorkflow node names based on the selected workflow (#18953) --- .../WorkflowSelectorParameterInput.vue | 18 +++ .../useWorkflowResourcesLocator.test.ts | 111 ++++++++++++++++++ .../useWorkflowResourcesLocator.ts | 31 +++++ 3 files changed, 160 insertions(+) create mode 100644 packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.test.ts diff --git a/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue index 215b6902ac6..a77744c1905 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue @@ -92,6 +92,7 @@ const { searchFilter, onSearchFilter, getWorkflowName, + renameDefaultNodeName, populateNextWorkflowsPage, setWorkflowsResources, reloadWorkflows, @@ -174,6 +175,10 @@ function onListItemSelected(value: NodeParameterValue) { telemetry.track('User chose sub-workflow', {}); onInputChange(value); hideDropdown(); + // we rename defaults here to allow selecting the same workflow to + // update the name, as we don't eagerly update a changed workflow name + // but rather only react on changed id elsewhere + renameDefaultNodeName(value); } function onInputFocus(): void { @@ -239,6 +244,19 @@ watch( }, ); +watch( + () => props.modelValue, + (val, old) => { + // We update the name only if the actual ID changed + // Because eagerly renaming the node when the target sub-workflow + // changed name means the workflow becomes unsaved and changed just by + // opening the ExecuteWorkflow node referencing the renamed workflow + if (old.value !== val.value) { + renameDefaultNodeName(val.value); + } + }, +); + onClickOutside(dropdown, () => { isDropdownVisible.value = false; }); diff --git a/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.test.ts b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.test.ts new file mode 100644 index 00000000000..f18971f1d82 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useNDVStore } from '@/stores/ndv.store'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; +import type { IWorkflowDb } from '@/Interface'; +import type { Router } from 'vue-router'; +import { createTestingPinia } from '@pinia/testing'; + +const useCanvasOperations = vi.hoisted(() => vi.fn()); + +vi.mock('@/composables/useCanvasOperations', () => ({ + useCanvasOperations, +})); + +describe('useWorkflowResourcesLocator', () => { + let workflowsStoreMock: MockedStore; + let ndvStoreMock: MockedStore; + + const renameNodeMock = vi.fn(); + const routerMock = { resolve: vi.fn() } as unknown as Router; + + beforeEach(() => { + vi.clearAllMocks(); + + createTestingPinia(); + workflowsStoreMock = mockedStore(useWorkflowsStore); + ndvStoreMock = mockedStore(useNDVStore); + + useCanvasOperations.mockReturnValue({ renameNode: renameNodeMock }); + }); + + describe('renameDefaultNodeName', () => { + it.each([ + { + activeNodeName: 'Execute Workflow', + workflowId: 'workflow-id', + mockedWorkflow: { name: 'Test Workflow' }, + expectedRename: "Call 'Test Workflow'", + expectedCalledWith: 'Execute Workflow', + }, + { + activeNodeName: 'Call n8n Workflow Tool', + workflowId: 'workflow-id', + mockedWorkflow: { name: 'Test Workflow' }, + expectedRename: "Call 'Test Workflow'", + expectedCalledWith: 'Call n8n Workflow Tool', + }, + { + activeNodeName: "Call 'Old Workflow'", + workflowId: 'workflow-id', + mockedWorkflow: { name: 'New Workflow' }, + expectedRename: "Call 'New Workflow'", + expectedCalledWith: "Call 'Old Workflow'", + }, + ])( + 'should rename the node correctly for activeNodeName: $activeNodeName', + ({ activeNodeName, workflowId, mockedWorkflow, expectedRename, expectedCalledWith }) => { + const { renameDefaultNodeName } = useWorkflowResourcesLocator(routerMock); + + ndvStoreMock.activeNodeName = activeNodeName; + workflowsStoreMock.getWorkflowById.mockReturnValue( + mockedWorkflow as unknown as IWorkflowDb, + ); + + renameDefaultNodeName(workflowId); + + expect(workflowsStoreMock.getWorkflowById).toHaveBeenCalledWith(workflowId); + expect(renameNodeMock).toHaveBeenCalledWith(expectedCalledWith, expectedRename); + }, + ); + + it('should not rename the node for invalid workflowId', () => { + const { renameDefaultNodeName } = useWorkflowResourcesLocator(routerMock); + const workflowId = 123; + + renameDefaultNodeName(workflowId); + + expect(renameNodeMock).not.toHaveBeenCalled(); + }); + + it('should not rename the node for workflowId: workflow-id with null mockedWorkflow', () => { + const { renameDefaultNodeName } = useWorkflowResourcesLocator(routerMock); + const workflowId = 'workflow-id'; + const activeNodeName = 'Execute Workflow'; + + ndvStoreMock.activeNodeName = activeNodeName; + workflowsStoreMock.getWorkflowById.mockReturnValue(null as unknown as IWorkflowDb); + + renameDefaultNodeName(workflowId); + + expect(workflowsStoreMock.getWorkflowById).toHaveBeenCalledWith(workflowId); + expect(renameNodeMock).not.toHaveBeenCalled(); + }); + + it('should not rename the node for workflowId: workflow-id with activeNodeName: Some Other Node', () => { + const { renameDefaultNodeName } = useWorkflowResourcesLocator(routerMock); + const workflowId = 'workflow-id'; + const activeNodeName = 'Some Other Node'; + const mockedWorkflow = { name: 'Test Workflow' }; + + ndvStoreMock.activeNodeName = activeNodeName; + workflowsStoreMock.getWorkflowById.mockReturnValue(mockedWorkflow as unknown as IWorkflowDb); + + renameDefaultNodeName(workflowId); + + expect(workflowsStoreMock.getWorkflowById).not.toHaveBeenCalled(); + expect(renameNodeMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts index 4b4976d5c1c..63351613154 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts +++ b/packages/frontend/editor-ui/src/components/WorkflowSelectorParameterInput/useWorkflowResourcesLocator.ts @@ -5,9 +5,15 @@ import type { Router } from 'vue-router'; import { VIEWS } from '@/constants'; import type { IWorkflowDb } from '@/Interface'; +import type { NodeParameterValue } from 'n8n-workflow'; +import { useNDVStore } from '@/stores/ndv.store'; +import { useCanvasOperations } from '@/composables/useCanvasOperations'; export function useWorkflowResourcesLocator(router: Router) { const workflowsStore = useWorkflowsStore(); + const ndvStore = useNDVStore(); + const { renameNode } = useCanvasOperations(); + const workflowsResources = ref>([]); const isLoadingResources = ref(true); const searchFilter = ref(''); @@ -77,10 +83,34 @@ export function useWorkflowResourcesLocator(router: Router) { return id; } + function getWorkflowBaseName(id: string): string | null { + const workflow = workflowsStore.getWorkflowById(id); + if (workflow) { + return workflow.name; + } + return null; + } + function onSearchFilter(filter: string) { searchFilter.value = filter; } + function renameDefaultNodeName(workflowId: NodeParameterValue) { + if (typeof workflowId !== 'string') return; + + const nodeName = ndvStore.activeNodeName; + if ( + nodeName === 'Execute Workflow' || + nodeName === 'Call n8n Workflow Tool' || + (nodeName?.startsWith("Call '") && nodeName?.endsWith("'")) + ) { + const baseName = getWorkflowBaseName(workflowId); + if (baseName !== null) { + void renameNode(nodeName, `Call '${baseName}'`); + } + } + } + return { workflowsResources, isLoadingResources, @@ -91,6 +121,7 @@ export function useWorkflowResourcesLocator(router: Router) { getWorkflowUrl, onSearchFilter, getWorkflowName, + renameDefaultNodeName, populateNextWorkflowsPage, setWorkflowsResources, };