mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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
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:
parent
03d27b7da7
commit
49e28b57ae
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user