fix(editor): Keep workflows moved out of a folder visible in the source control push modal (#37128)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Publish API Schema / Cloudflare Pages (push) Waiting to run
Build: Benchmark Image / build (push) Has been cancelled
Util: Refresh cubic schema / Refresh vendored cubic schema (push) Has been cancelled

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sandra Zollner 2026-08-31 09:58:39 +00:00 committed by GitHub
parent 03d27b7da7
commit 49e28b57ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 179 additions and 12 deletions

View File

@ -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(),

View File

@ -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', () => {

View File

@ -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,
});
}

View File

@ -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",

View File

@ -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'],

View File

@ -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"
/>
</template>
<N8nBadge
:theme="getStatusTheme(row.file.status)"
style="height: 25px"
<N8nTooltip
:content="renderMovedTooltip(row.file)"
:disabled="!renderMovedTooltip(row.file)"
placement="top"
>
{{ getStatusText(row.file.status) }}
</N8nBadge>
<N8nBadge
:theme="getStatusTheme(row.file.status)"
style="height: 25px"
>
{{ getStatusText(row.file.status) }}
</N8nBadge>
</N8nTooltip>
<template v-if="isWorkflowDiffsEnabled">
<N8nTooltip
v-if="row.file.type === SOURCE_CONTROL_FILE_TYPE.workflow"

View File

@ -169,6 +169,28 @@ describe('source control utils', () => {
]);
});
it('includes the remote/prior folder of a moved workflow so its source folder stays selectable', () => {
const options = buildFolderFilterOptions([
{
id: 'wf-moved',
name: 'Moved workflow',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: true,
file: '/wf-moved.json',
updatedAt: '2025-01-01T00:00:00.000Z',
folderPath: ['Archive'],
remoteFolderPath: ['Production'],
},
]);
expect(options).toEqual([
{ label: 'Archive', value: 'Archive' },
{ label: 'Production', value: 'Production' },
]);
});
it('returns empty array when workflows have no folder paths', () => {
const options = buildFolderFilterOptions([
{

View File

@ -215,12 +215,14 @@ export const buildFolderFilterOptions = (workflows: SourceControlledFile[]) => {
const folderPathSet = new Set<string>();
for (const workflow of workflows) {
const pathSegments = workflow.folderPath ?? [];
let path = '';
// Include the prior/remote path so a moved workflow's source folder stays selectable.
for (const pathSegments of [workflow.folderPath, workflow.remoteFolderPath]) {
let path = '';
for (const segment of pathSegments) {
path = path ? `${path}/${segment}` : segment;
folderPathSet.add(path);
for (const segment of pathSegments ?? []) {
path = path ? `${path}/${segment}` : segment;
folderPathSet.add(path);
}
}
}