mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
fix(editor): Improve loading states of move workflow/directory modal (#25287)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+35
-26
@@ -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);
|
||||
|
||||
+90
-17
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+138
-76
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user