fix(editor): Improve loading states of move workflow/directory modal (#25287)

This commit is contained in:
Svetoslav Dekov
2026-02-12 09:38:24 +00:00
committed by GitHub
parent 3e079ec901
commit e7dbd376e3
10 changed files with 354 additions and 161 deletions
@@ -4143,6 +4143,7 @@
"projects.sharing.select.placeholder": "Select project or user",
"projects.sharing.select.placeholder.user": "Share with user(s)",
"projects.sharing.select.placeholder.project": "Share with projects or users",
"projects.sharing.personalSpace": "Personal space",
"projects.error.title": "Project error",
"projects.create.limit": "{count} project | {count} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
@@ -1401,16 +1401,20 @@ const moveFolder = async (payload: {
options?: {
skipFetch?: boolean;
skipNavigation?: boolean;
skipApiCall?: boolean;
};
}) => {
if (!route.params.projectId) return;
loading.value = true;
try {
await foldersStore.moveFolder(
route.params.projectId as string,
payload.folder.id,
payload.newParent.type === 'folder' ? payload.newParent.id : '0',
);
if (!payload.options?.skipApiCall) {
await foldersStore.moveFolder(
route.params.projectId as string,
payload.folder.id,
payload.newParent.type === 'folder' ? payload.newParent.id : '0',
);
}
const isCurrentFolder = currentFolderId.value === payload.folder.id;
const newFolderURL = router.resolve({
@@ -1454,6 +1458,8 @@ const moveFolder = async (payload: {
}
} catch (error) {
toast.showError(error, i18n.baseText('folders.move.error.title'));
} finally {
loading.value = false;
}
};
@@ -1467,17 +1473,9 @@ const onFolderTransferred = async (payload: {
parentFolder: { id: string | undefined; name: string };
canAccess: boolean;
};
shareCredentials?: string[];
}) => {
loading.value = true;
try {
await foldersStore.moveFolderToProject(
payload.source.projectId,
payload.source.folder.id,
payload.destination.projectId,
payload.destination.parentFolder.id,
payload.shareCredentials,
);
const isCurrentFolder = currentFolderId.value === payload.source.folder.id;
const newFolderURL = router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
@@ -1537,6 +1535,8 @@ const onFolderTransferred = async (payload: {
}
} catch (error) {
toast.showError(error, i18n.baseText('folders.move.error.title'));
} finally {
loading.value = false;
}
};
@@ -1577,17 +1577,9 @@ const onWorkflowTransferred = async (payload: {
parentFolder: { id: string | undefined; name: string };
canAccess: boolean;
};
shareCredentials?: string[];
}) => {
loading.value = true;
try {
await projectsStore.moveResourceToProject(
'workflow',
payload.source.workflow.id,
payload.destination.projectId,
payload.destination.parentFolder.id,
payload.shareCredentials,
);
await refreshWorkflows();
if (payload.destination.canAccess) {
@@ -1627,6 +1619,8 @@ const onWorkflowTransferred = async (payload: {
}
} catch (error) {
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
} finally {
loading.value = false;
}
};
@@ -1635,9 +1629,12 @@ const onWorkflowMoved = async (payload: {
newParent: { id: string; name: string; type: 'folder' | 'project' };
options?: {
skipFetch?: boolean;
skipApiCall?: boolean;
};
}) => {
if (!route.params.projectId) return;
loading.value = true;
try {
const newFolderURL = router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
@@ -1646,13 +1643,15 @@ const onWorkflowMoved = async (payload: {
folderId: payload.newParent.type === 'folder' ? payload.newParent.id : undefined,
},
}).href;
const workflowResource = workflowsAndFolders.value.find(
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
);
await workflowsStore.updateWorkflow(payload.workflow.id, {
parentFolderId: payload.newParent.type === 'folder' ? payload.newParent.id : '0',
versionId: workflowResource?.versionId,
});
if (!payload.options?.skipApiCall) {
const workflowResource = workflowsAndFolders.value.find(
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
);
await workflowsStore.updateWorkflow(payload.workflow.id, {
parentFolderId: payload.newParent.type === 'folder' ? payload.newParent.id : '0',
versionId: workflowResource?.versionId,
});
}
if (!payload.options?.skipFetch) {
await fetchWorkflows();
}
@@ -1679,6 +1678,8 @@ const onWorkflowMoved = async (payload: {
});
} catch (error) {
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
} finally {
loading.value = false;
}
};
@@ -4,7 +4,7 @@ import type { ChangeLocationSearchResult } from '../folders.types';
import { useFoldersStore } from '../folders.store';
import { computed, ref, watch } from 'vue';
import { N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { N8nIcon, N8nLoading, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
/**
* This component is used to select a folder within a project.
* If parentFolderId is provided it will filter out the parent folder from the results.
@@ -36,7 +36,6 @@ const i18n = useI18n();
const foldersStore = useFoldersStore();
const availableLocations = ref<ChangeLocationSearchResult[]>([]);
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
const selectedLocationId = computed<string | null>({
get: () => props.selectedLocation?.id ?? null,
set: (id) => {
@@ -50,6 +49,7 @@ const selectedLocationId = computed<string | null>({
});
const loading = ref(false);
const initialLoading = ref(true);
const fetchAvailableLocations = async (query?: string) => {
loading.value = true;
@@ -66,44 +66,41 @@ const fetchAvailableLocations = async (query?: string) => {
const rootFolderName = i18n.baseText('folders.move.project.root.name');
const isQueryMatchesRoot = !query || rootFolderName.toLowerCase().includes(query?.toLowerCase());
const isTransfer = props.selectedProjectId !== props.currentProjectId;
// Finally always add project root to the results (if folder is not already in root)
if (isQueryMatchesRoot && (!!props.parentFolderId || isTransfer)) {
availableLocations.value.unshift({
id: props.selectedProjectId,
name: rootFolderName,
resource: 'project',
createdAt: '',
updatedAt: '',
workflowCount: 0,
subFolderCount: 0,
path: [],
});
// Finally always add project root to the results
const projectRoot: ChangeLocationSearchResult = {
id: props.selectedProjectId,
name: rootFolderName,
resource: 'project',
createdAt: '',
updatedAt: '',
workflowCount: 0,
subFolderCount: 0,
path: [],
};
if (isQueryMatchesRoot) {
availableLocations.value.unshift(projectRoot);
}
// Auto-select project root if no location is selected (initial load, not search)
if (!query && !props.selectedLocation) {
emit('location:selected', projectRoot);
}
loading.value = false;
initialLoading.value = false;
};
watch(
() => [props.selectedProjectId, props.currentFolderId, props.parentFolderId],
() => {
availableLocations.value = [];
initialLoading.value = true;
void fetchAvailableLocations();
},
{ immediate: true },
);
function focusOnInput() {
// To make the dropdown automatically open focused and positioned correctly
// we must wait till the modal opening animation is done. ElModal triggers an 'opened' event
// when the animation is done, and once that happens, we can focus on the input.
moveFolderDropdown.value?.focusOnInput();
}
defineExpose({
focusOnInput,
});
const maxPathLength = 4;
const separator = '/';
@@ -114,7 +111,9 @@ const isTopLevelFolder = (location: ChangeLocationSearchResult, index: number) =
<template>
<div :class="$style['move-folder-dropdown']" data-test-id="move-to-folder-dropdown">
<N8nLoading v-if="initialLoading" :rows="1" :class="$style['loading-skeleton']" />
<N8nSelect
v-else
ref="moveFolderDropdown"
v-model="selectedLocationId"
:filterable="true"
@@ -184,6 +183,16 @@ const isTopLevelFolder = (location: ChangeLocationSearchResult, index: number) =
padding-top: var(--spacing--2xs);
}
.loading-skeleton {
width: 100%;
margin: 0;
:global(.el-skeleton__item) {
height: 40px;
margin: 0;
}
}
.folder-select-item {
display: flex;
gap: var(--spacing--2xs);
@@ -25,6 +25,7 @@ import type {
import type { ChangeLocationSearchResult } from '../folders.types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useFoldersStore } from '../folders.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import MoveToFolderModal from './MoveToFolderModal.vue';
@@ -72,6 +73,7 @@ let uiStore: MockedStore<typeof useUIStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let credentialsStore: MockedStore<typeof useCredentialsStore>;
let workflowsListStore: MockedStore<typeof useWorkflowsListStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let foldersStore: MockedStore<typeof useFoldersStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
@@ -176,8 +178,14 @@ describe('MoveToFolderModal', () => {
totalWorkflows: 0,
totalSubFolders: 0,
});
foldersStore.moveFolder = vi.fn().mockResolvedValue(undefined);
foldersStore.moveFolderToProject = vi.fn().mockResolvedValue(undefined);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.updateWorkflow = vi.fn().mockResolvedValue({});
projectsStore = mockedStore(useProjectsStore);
projectsStore.moveResourceToProject = vi.fn().mockResolvedValue(undefined);
projectsStore.currentProject = personalProject as unknown as Project;
projectsStore.currentProjectId = personalProject.id;
@@ -379,7 +387,10 @@ describe('MoveToFolderModal', () => {
const folderSelect = getByTestId('move-to-folder-dropdown');
expect(folderSelect).toBeVisible();
expect(within(folderSelect).getByRole('combobox')).toHaveValue('');
// Project root is auto-selected by the dropdown after loading
await waitFor(() =>
expect(within(folderSelect).getByRole('combobox')).toHaveValue('No folder (project root)'),
);
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
expect(folderSelectDropdownItems).toHaveLength(2); // root, test
@@ -388,7 +399,7 @@ describe('MoveToFolderModal', () => {
expect(within(folderSelect).getByRole('combobox')).toHaveValue('test');
});
it('should clear selected folder when switching projects', async () => {
it('should reset to project root when switching projects', async () => {
settingsStore.settings = enableSharing;
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
@@ -417,7 +428,8 @@ describe('MoveToFolderModal', () => {
);
await userEvent.click(teamProject as Element);
expect(within(folderSelect).getByRole('combobox')).toHaveValue('');
// After switching projects, the project root should be selected
expect(within(folderSelect).getByRole('combobox')).toHaveValue('No folder (project root)');
});
it('should move selected folder on submit', async () => {
@@ -436,7 +448,8 @@ describe('MoveToFolderModal', () => {
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
// Wait for dropdown to load and auto-select project root
await waitFor(() => expect(submitButton).toBeEnabled());
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
@@ -445,6 +458,11 @@ describe('MoveToFolderModal', () => {
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(foldersStore.moveFolder).toHaveBeenCalledWith(
personalProject.id,
TEST_FOLDER_RESOURCE.id,
folder.id,
);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-moved', {
newParent: {
id: folder.id,
@@ -452,6 +470,7 @@ describe('MoveToFolderModal', () => {
type: folder.resource,
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
options: { skipApiCall: true },
});
});
@@ -480,7 +499,7 @@ describe('MoveToFolderModal', () => {
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
await waitFor(() => expect(submitButton).toBeEnabled());
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
@@ -489,6 +508,15 @@ describe('MoveToFolderModal', () => {
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
await waitFor(() => {
expect(foldersStore.moveFolderToProject).toHaveBeenCalledWith(
personalProject.id,
TEST_FOLDER_RESOURCE.id,
teamProjects[0].id,
folder.id,
undefined,
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
source: {
projectId: personalProject.id,
@@ -505,7 +533,6 @@ describe('MoveToFolderModal', () => {
},
canAccess: true,
},
shareCredentials: undefined,
});
});
@@ -545,6 +572,15 @@ describe('MoveToFolderModal', () => {
await userEvent.click(getByTestId('move-modal-share-credentials-checkbox'));
await userEvent.click(submitButton);
await waitFor(() => {
expect(foldersStore.moveFolderToProject).toHaveBeenCalledWith(
personalProject.id,
TEST_FOLDER_RESOURCE.id,
teamProjects[0].id,
folder.id,
[shareableUsedCredential.id],
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
source: {
projectId: personalProject.id,
@@ -561,7 +597,6 @@ describe('MoveToFolderModal', () => {
},
canAccess: true,
},
shareCredentials: [shareableUsedCredential.id],
});
});
@@ -600,6 +635,15 @@ describe('MoveToFolderModal', () => {
await userEvent.click(folderSelectDropdownItems[1]);
await userEvent.click(submitButton);
await waitFor(() => {
expect(foldersStore.moveFolderToProject).toHaveBeenCalledWith(
personalProject.id,
TEST_FOLDER_RESOURCE.id,
teamProjects[0].id,
folder.id,
undefined,
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
source: {
projectId: personalProject.id,
@@ -616,7 +660,6 @@ describe('MoveToFolderModal', () => {
},
canAccess: true,
},
shareCredentials: undefined,
});
});
@@ -646,11 +689,19 @@ describe('MoveToFolderModal', () => {
await userEvent.click(anotherUserPersonalProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeEnabled();
await waitFor(() => expect(submitButton).toBeEnabled());
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
await waitFor(() => {
expect(foldersStore.moveFolderToProject).toHaveBeenCalledWith(
personalProject.id,
TEST_FOLDER_RESOURCE.id,
anotherUser.id,
undefined,
undefined,
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
source: {
projectId: personalProject.id,
@@ -663,11 +714,10 @@ describe('MoveToFolderModal', () => {
projectId: anotherUser.id,
parentFolder: {
id: undefined,
name: anotherUser.name,
name: `${anotherUser.name} (Personal space)`,
},
canAccess: false,
},
shareCredentials: undefined,
});
});
@@ -702,7 +752,8 @@ describe('MoveToFolderModal', () => {
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
// Wait for dropdown to load and auto-select project root
await waitFor(() => expect(submitButton).toBeEnabled());
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
@@ -711,6 +762,11 @@ describe('MoveToFolderModal', () => {
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
await waitFor(() => {
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(TEST_WORKFLOW_RESOURCE.id, {
parentFolderId: folder.id,
});
});
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-moved', {
newParent: {
id: folder.id,
@@ -722,6 +778,7 @@ describe('MoveToFolderModal', () => {
name: TEST_WORKFLOW_RESOURCE.name,
oldParentId: TEST_WORKFLOW_RESOURCE.parentFolderId,
},
options: { skipApiCall: true },
});
});
@@ -752,7 +809,7 @@ describe('MoveToFolderModal', () => {
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
await waitFor(() => expect(submitButton).toBeEnabled());
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
@@ -761,6 +818,15 @@ describe('MoveToFolderModal', () => {
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
await waitFor(() => {
expect(projectsStore.moveResourceToProject).toHaveBeenCalledWith(
'workflow',
TEST_WORKFLOW_RESOURCE.id,
teamProjects[0].id,
folder.id,
undefined,
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-transferred', {
source: {
projectId: personalProject.id,
@@ -777,7 +843,6 @@ describe('MoveToFolderModal', () => {
},
canAccess: true,
},
shareCredentials: undefined,
});
});
@@ -810,6 +875,15 @@ describe('MoveToFolderModal', () => {
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(submitButton);
await waitFor(() => {
expect(projectsStore.moveResourceToProject).toHaveBeenCalledWith(
'workflow',
TEST_WORKFLOW_RESOURCE.id,
anotherUser.id,
undefined,
undefined,
);
});
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-transferred', {
source: {
projectId: personalProject.id,
@@ -822,11 +896,10 @@ describe('MoveToFolderModal', () => {
projectId: anotherUser.id,
parentFolder: {
id: undefined,
name: anotherUser.name,
name: `${anotherUser.name} (Personal space)`,
},
canAccess: false,
},
shareCredentials: undefined,
});
});
});
@@ -8,6 +8,7 @@ import { useFoldersStore } from '../folders.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { type EventBus, createEventBus } from '@n8n/utils/event-bus';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import type {
@@ -28,8 +29,10 @@ import ProjectSharing from '@/features/collaboration/projects/components/Project
import {
ResourceType,
getTruncatedProjectName,
splitName,
} from '@/features/collaboration/projects/projects.utils';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useToast } from '@/app/composables/useToast';
import { I18nT } from 'vue-i18n';
import { N8nButton, N8nCallout, N8nCheckbox, N8nText, N8nTooltip } from '@n8n/design-system';
@@ -62,16 +65,18 @@ const props = defineProps<Props>();
const i18n = useI18n();
const modalBus = createEventBus();
const moveToFolderDropdown = ref<InstanceType<typeof MoveToFolderDropdown>>();
const foldersStore = useFoldersStore();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowsStore = useWorkflowsStore();
const toast = useToast();
const selectedFolder = ref<ChangeLocationSearchResult | null>(null);
const selectedProject = ref<ProjectSharingData | null>(projectsStore.currentProject);
const loading = ref(false);
const isPersonalProject = computed(() => {
return selectedProject.value?.type === ProjectTypes.Personal;
});
@@ -182,12 +187,22 @@ const onFolderSelected = (payload: ChangeLocationSearchResult) => {
selectedFolder.value = payload;
};
const getPersonalProjectLabel = (projectName?: string | null) => {
const { name } = splitName(projectName ?? '');
const personalSpaceText = i18n.baseText('projects.sharing.personalSpace');
return name ? `${name} (${personalSpaceText})` : personalSpaceText;
};
const targetProjectName = computed(() => {
if (selectedProject.value?.type === ProjectTypes.Personal) {
return getPersonalProjectLabel(selectedProject.value?.name);
}
return getTruncatedProjectName(selectedProject.value?.name);
});
const onSubmit = () => {
if (!selectedProject.value) {
const onSubmit = async () => {
if (!selectedProject.value || loading.value) {
return;
}
@@ -206,87 +221,133 @@ const onSubmit = () => {
type: 'project',
};
if (props.data.resourceType === 'folder') {
if (selectedProject.value.id !== projectsStore.currentProject?.id) {
props.data.workflowListEventBus.emit('folder-transferred', {
source: {
projectId: projectsStore.currentProject?.id,
folder: {
id: props.data.resource.id,
name: props.data.resource.name,
},
},
destination: {
projectId: selectedProject.value.id,
parentFolder: {
id:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.id
: undefined,
name:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.name
: targetProjectName.value,
},
canAccess: isFolderSelectable.value,
},
shareCredentials: shareUsedCredentials.value
loading.value = true;
try {
if (props.data.resourceType === 'folder') {
if (selectedProject.value.id !== projectsStore.currentProject?.id) {
// Transfer folder to another project
const destinationParentFolderId =
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.id
: undefined;
const shareCredentials = shareUsedCredentials.value
? shareableCredentials.value.map((c) => c.id)
: undefined,
});
: undefined;
await foldersStore.moveFolderToProject(
projectsStore.currentProject?.id ?? '',
props.data.resource.id,
selectedProject.value.id,
destinationParentFolderId,
shareCredentials,
);
props.data.workflowListEventBus.emit('folder-transferred', {
source: {
projectId: projectsStore.currentProject?.id,
folder: {
id: props.data.resource.id,
name: props.data.resource.name,
},
},
destination: {
projectId: selectedProject.value.id,
parentFolder: {
id: destinationParentFolderId,
name:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.name
: targetProjectName.value,
},
canAccess: isFolderSelectable.value,
},
});
} else {
// Move folder within same project
const newParentId = newParent.type === 'folder' ? newParent.id : '0';
await foldersStore.moveFolder(
projectsStore.currentProject?.id ?? '',
props.data.resource.id,
newParentId,
);
props.data.workflowListEventBus.emit('folder-moved', {
newParent,
folder: { id: props.data.resource.id, name: props.data.resource.name },
options: { skipApiCall: true },
});
}
} else {
props.data.workflowListEventBus.emit('folder-moved', {
newParent,
folder: { id: props.data.resource.id, name: props.data.resource.name },
});
}
} else {
if (isTransferringOwnership.value) {
props.data.workflowListEventBus.emit('workflow-transferred', {
source: {
projectId: projectsStore.currentProject?.id,
if (isTransferringOwnership.value) {
// Transfer workflow to another project
const destinationParentFolderId =
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.id
: undefined;
const shareCredentials = shareUsedCredentials.value
? shareableCredentials.value.map((c) => c.id)
: undefined;
await projectsStore.moveResourceToProject(
'workflow',
props.data.resource.id,
selectedProject.value.id,
destinationParentFolderId,
shareCredentials,
);
props.data.workflowListEventBus.emit('workflow-transferred', {
source: {
projectId: projectsStore.currentProject?.id,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
},
},
destination: {
projectId: selectedProject.value.id,
parentFolder: {
id: destinationParentFolderId,
name:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.name
: targetProjectName.value,
},
canAccess: isFolderSelectable.value,
},
});
} else {
// Move workflow within same project
const newParentFolderId = newParent.type === 'folder' ? newParent.id : '0';
await workflowsStore.updateWorkflow(props.data.resource.id, {
parentFolderId: newParentFolderId,
});
props.data.workflowListEventBus.emit('workflow-moved', {
newParent,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
oldParentId: props.data.resource.parentFolderId,
},
},
destination: {
projectId: selectedProject.value.id,
parentFolder: {
id:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.id
: undefined,
name:
selectedFolder.value && selectedFolder.value.id !== selectedProject.value.id
? selectedFolder.value.name
: targetProjectName.value,
},
canAccess: isFolderSelectable.value,
},
shareCredentials: shareUsedCredentials.value
? shareableCredentials.value.map((c) => c.id)
: undefined,
});
} else {
props.data.workflowListEventBus.emit('workflow-moved', {
newParent,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
oldParentId: props.data.resource.parentFolderId,
},
});
options: { skipApiCall: true },
});
}
}
uiStore.closeModal(MOVE_FOLDER_MODAL_KEY);
} catch (error) {
const errorTitleKey =
props.data.resourceType === 'folder'
? 'folders.move.error.title'
: 'folders.move.workflow.error.title';
toast.showError(error, i18n.baseText(errorTitleKey));
} finally {
loading.value = false;
}
uiStore.closeModal(MOVE_FOLDER_MODAL_KEY);
};
modalBus.on('opened', () => {
moveToFolderDropdown.value?.focusOnInput();
});
const descriptionMessage = computed(() => {
let folderText = '';
let workflowText = '';
@@ -413,7 +474,6 @@ onMounted(async () => {
{{ i18n.baseText('folders.move.modal.folder.label') }}
</N8nText>
<MoveToFolderDropdown
ref="moveToFolderDropdown"
:selected-location="selectedFolder"
:selected-project-id="selectedProject.id"
:current-project-id="currentResourceProjectId"
@@ -494,12 +554,14 @@ onMounted(async () => {
<N8nButton
type="secondary"
:label="i18n.baseText('generic.cancel')"
:disabled="loading"
float="right"
data-test-id="cancel-move-folder-button"
@click="close"
/>
<N8nButton
:disabled="!selectedFolder && isFolderSelectable"
:disabled="(!selectedFolder && isFolderSelectable) || loading"
:loading="loading"
:label="
i18n.baseText('folders.move.modal.confirm', {
interpolate: {
@@ -34,7 +34,7 @@ export class SettingsUsersPage extends BasePage {
await this.page.getByTestId('action-transfer').click();
}
async transferData(email: string) {
async transferData(emailOrName: string) {
await this.page
.getByRole('radio', {
name: 'Transfer their workflows and credentials to another user or project',
@@ -44,7 +44,19 @@ export class SettingsUsersPage extends BasePage {
.click({ force: true });
await this.page.getByPlaceholder('Select project or user').click();
await this.page.getByTestId('project-sharing-info').filter({ hasText: email }).click();
const projectSharingInfo = this.page.getByTestId('project-sharing-info');
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const byEmail = projectSharingInfo.filter({ hasText: emailOrName });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await projectSharingInfo
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
await this.page.getByRole('button', { name: 'Delete' }).click();
}
@@ -17,12 +17,21 @@ export class WorkflowSharingModal extends BasePage {
return this.page.locator('.el-select-dropdown:visible');
}
async addUser(email: string) {
async addUser(emailOrName: string) {
await this.clickByTestId('project-sharing-select');
await this.page
.locator('.el-select-dropdown__item')
.filter({ hasText: email.toLowerCase() })
.click();
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const dropdown = this.page.locator('.el-select-dropdown__item');
const byEmail = dropdown.filter({ hasText: emailOrName.toLowerCase() });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, the email is not shown, so try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await dropdown
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
}
async save() {
@@ -164,11 +164,23 @@ export class CredentialModal extends BaseModal {
/**
* Add a user to credential sharing
* @param email - User email to share with
* @param emailOrName - User email or name to share with
*/
async addUserToSharing(email: string): Promise<void> {
async addUserToSharing(emailOrName: string): Promise<void> {
await this.getUsersSelect().click();
await this.getVisibleDropdown().getByText(email.toLowerCase(), { exact: false }).click();
const dropdown = this.getVisibleDropdown();
// Wait for dropdown content to load
await dropdown.locator('.el-select-dropdown__item').first().waitFor({ state: 'visible' });
// Try to find by email or name (personal projects now show "Personal space" instead of email)
const byEmail = dropdown.getByText(emailOrName.toLowerCase(), { exact: false });
if ((await byEmail.count()) > 0) {
await byEmail.click();
} else {
// For personal projects, try matching by name part of email
const namePart = emailOrName.split('@')[0].replace(/[.-]/g, ' ');
await dropdown.getByText(new RegExp(namePart, 'i')).first().click();
}
}
/**
@@ -27,7 +27,19 @@ export class ResourceMoveModal {
}
async selectProjectOption(projectNameOrEmail: string): Promise<void> {
await this.page.getByRole('option').filter({ hasText: projectNameOrEmail }).click();
const options = this.page.getByRole('option');
// Try to find by exact text (project name or email)
const byExact = options.filter({ hasText: projectNameOrEmail });
if ((await byExact.count()) > 0) {
await byExact.click();
} else {
// For personal projects, the email is not shown, so try matching by name part of email
const namePart = projectNameOrEmail.split('@')[0].replace(/[.-]/g, ' ');
await options
.filter({ hasText: new RegExp(namePart, 'i') })
.first()
.click();
}
}
async clickMoveCredentialButton(): Promise<void> {
@@ -69,8 +69,10 @@ test.describe('Access Control Boundaries', () => {
await expect(adminN8n.credentials.credentialModal.getUsersSelect()).toBeVisible();
await adminN8n.credentials.credentialModal.getUsersSelect().click();
// Personal projects show "Name (Personal space)" instead of email
const adminName = `${admin.firstName} ${admin.lastName}`;
await expect(
adminN8n.credentials.credentialModal.getVisibleDropdown().getByText(admin.email),
adminN8n.credentials.credentialModal.getVisibleDropdown().getByText(adminName),
).toBeVisible();
});