From 49e28b57ae94010c7c8458e1bb36abd65bfda7c6 Mon Sep 17 00:00:00 2001 From: Sandra Zollner Date: Mon, 31 Aug 2026 09:58:39 +0000 Subject: [PATCH] fix(editor): Keep workflows moved out of a folder visible in the source control push modal (#37128) Co-authored-by: Claude Opus 4.8 (1M context) --- .../schemas/source-controlled-file.schema.ts | 1 + .../source-control-status.service.test.ts | 58 +++++++++++++++++++ .../source-control-status.service.ee.ts | 7 +++ .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../components/SourceControlPushModal.test.ts | 54 +++++++++++++++++ .../components/SourceControlPushModal.vue | 36 +++++++++--- .../sourceControl.utils.test.ts | 22 +++++++ .../sourceControl.ee/sourceControl.utils.ts | 12 ++-- 8 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts index 7ade06e3448..0c9e7a451c7 100644 --- a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts +++ b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts @@ -57,6 +57,7 @@ export const SourceControlledFileSchema = z.object({ isRemoteArchived: z.boolean().optional(), parentFolderId: z.string().nullable().optional(), folderPath: z.array(z.string()).optional(), + remoteFolderPath: z.array(z.string()).optional(), owner: ResourceOwnerSchema.optional(), // Resource owner can be a personal email or team information publishingError: z.string().optional(), publishingErrorDetails: workflowPublishBlockedDetailsSchema.optional(), diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts index bf90cbe0435..dad281b6419 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts @@ -1041,6 +1041,64 @@ describe('getStatus', () => { expect(workflow?.parentFolderId).toBe('local-child'); expect(workflow?.folderPath).toEqual(['Local Parent', 'Local Child']); }); + + it('exposes the remote folder path for a moved workflow so it stays visible under the source folder', async () => { + const local = createWorkflow({ + id: 'wf-moved-foldered', + versionId: 'local-v1', + parentFolderId: 'local-child', + }); + const remote = createWorkflow({ + id: 'wf-moved-foldered', + versionId: 'remote-v2', + parentFolderId: 'remote-child', + }); + + sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([local]); + sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([remote]); + sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({ + folders: [], + }); + sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({ + folders: [ + { + id: 'remote-child', + name: 'Remote Child', + parentFolderId: null, + homeProjectId: 'project1', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', + }, + ], + }); + + const folderData = new Map([ + [ + 'local-child', + { id: 'local-child', name: 'Local Child', parentFolder: { id: 'local-parent' } }, + ], + ['local-parent', { id: 'local-parent', name: 'Local Parent', parentFolder: null }], + ]); + folderRepository.find.mockImplementation(async (options: any) => { + if (options?.where?.id?._value) { + const ids = options.where.id._value as string[]; + return ids.map((id: string) => folderData.get(id)).filter(Boolean) as any; + } + return []; + }); + + const result = await sourceControlStatusService.getStatus(user, { + direction: 'push', + verbose: false, + preferLocalVersion: true, + }); + + const workflow = result.find((f) => f.id === 'wf-moved-foldered'); + expect(workflow).toBeDefined(); + expect(workflow?.status).toBe('modified'); + expect(workflow?.folderPath).toEqual(['Local Parent', 'Local Child']); + expect(workflow?.remoteFolderPath).toEqual(['Remote Child']); + }); }); describe('owner changes', () => { diff --git a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts index 84a014f7771..8901c040cff 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts @@ -433,6 +433,12 @@ export class SourceControlStatusService { preferredParentFolderId, options.preferLocalVersion ? localFoldersById : remoteFoldersById, ); + // A move keeps only its new folderPath; expose the prior/remote path too so a + // filter on the source folder doesn't hide the pending move. + const remoteFolderPath = this.buildFolderPath( + remoteWorkflowWithSameId.parentFolderId, + remoteFoldersById, + ); const wfModified: SourceControlWorkflowVersionId = { ...localWorkflow, @@ -462,6 +468,7 @@ export class SourceControlStatusService { isRemoteArchived: archivedWorkflowIds.get(wfModified.id) ?? false, parentFolderId: preferredParentFolderId, folderPath: preferredFolderPath, + remoteFolderPath, owner: wfModified.owner, }); } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 0d24744b2a0..8c2b89f0fc3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4180,6 +4180,7 @@ "settings.sourceControl.modals.push.success.description": "was committed and pushed to your remote repository | were committed and pushed to your remote repository", "settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move them to a project first.", "settings.sourceControl.modals.push.currentWorkflow": "Current workflow", + "settings.sourceControl.modals.push.movedWorkflow": "Moved from {from} to {to}", "settings.sourceControl.status.modified": "Modified", "settings.sourceControl.status.deleted": "Deleted", "settings.sourceControl.status.created": "New", diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts index f736b9428f1..ad6e4fdc3dc 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts @@ -1498,6 +1498,60 @@ describe('SourceControlPushModal', () => { expect(optionLabels).toEqual(['Alpha', 'Prod', 'Prod / Analytics', 'Prod / Billing']); }); + it('keeps a workflow moved out of a folder visible when filtering by its source folder', async () => { + // Moved from Production (prior) into Archive (current). + const status: SourceControlledFile[] = [ + { + id: 'wf-moved', + name: 'Moved workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: true, + file: '/home/user/.n8n/git/workflows/wf-moved.json', + updatedAt: '2024-09-20T10:31:40.000Z', + folderPath: ['Archive'], + remoteFolderPath: ['Production'], + }, + ]; + + sourceControlStore.getAggregatedStatus.mockResolvedValue(status); + + const { getByTestId, getAllByTestId, getByText } = renderModal({ + pinia, + props: { + data: { + eventBus, + status, + }, + }, + }); + + await waitFor(() => { + expect(getByText('Commit and push changes')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1); + }); + + await userEvent.click(getByTestId('source-control-filter-dropdown')); + const folderSelect = getByTestId('source-control-folder-filter'); + const folderCombobox = within(folderSelect).getByRole('combobox'); + await userEvent.click(folderCombobox); + + const dropdownId = folderCombobox.getAttribute('aria-controls'); + await waitFor(() => { + const dropdown = document.getElementById(dropdownId as string); + expect(within(dropdown as HTMLElement).getByText('Production')).toBeInTheDocument(); + }); + + const dropdown = document.getElementById(dropdownId as string) as HTMLElement; + await userEvent.click(within(dropdown).getByText('Production')); + + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1); + }); + test.each([ ['credential', 'Credentials'], ['workflow', 'Workflows'], diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue index db1276ac7fe..10f3a3e5c34 100644 --- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue @@ -356,8 +356,12 @@ const sortedWorkflows = useSourceControlFileList({ return true; } - const workflowPath = (workflow.folderPath ?? []).join('/'); - return workflowPath === folderFilter || workflowPath.startsWith(`${folderFilter}/`); + // Match both the current and prior folder so a moved-out workflow stays visible. + const matchesFolder = (path: string[] | undefined) => { + const workflowPath = (path ?? []).join('/'); + return workflowPath === folderFilter || workflowPath.startsWith(`${folderFilter}/`); + }; + return matchesFolder(workflow.folderPath) || matchesFolder(workflow.remoteFolderPath); }, }); @@ -527,6 +531,18 @@ function renderUpdatedAt(file: SourceControlledFile) { return formatSourceControlUpdatedAt(file.updatedAt); } +// Non-empty only when the workflow's folder changed vs. the remote, i.e. it was moved. +function renderMovedTooltip(file: SourceControlledFile) { + const to = (file.folderPath ?? []).join('/'); + const from = (file.remoteFolderPath ?? []).join('/'); + if (file.remoteFolderPath === undefined || from === to) { + return ''; + } + return i18n.baseText('settings.sourceControl.modals.push.movedWorkflow', { + interpolate: { from: from || '/', to: to || '/' }, + }); +} + async function onCommitKeyDownEnter() { if (!isSubmitDisabled.value) { await commitAndPush(); @@ -1221,12 +1237,18 @@ onMounted(async () => { :show-badge-border="false" /> - - {{ getStatusText(row.file.status) }} - + + {{ getStatusText(row.file.status) }} + +