From e7dbd376e3f25212bfc57db0fa3c2e9cf5542c52 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Thu, 12 Feb 2026 09:38:24 +0000 Subject: [PATCH] fix(editor): Improve loading states of move workflow/directory modal (#25287) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../editor-ui/src/app/views/WorkflowsView.vue | 61 ++--- .../components/MoveToFolderDropdown.vue | 61 ++--- .../components/MoveToFolderModal.test.ts | 107 +++++++-- .../folders/components/MoveToFolderModal.vue | 214 +++++++++++------- .../playwright/pages/SettingsUsersPage.ts | 16 +- .../playwright/pages/WorkflowSharingModal.ts | 19 +- .../pages/components/CredentialModal.ts | 18 +- .../pages/components/ResourceMoveModal.ts | 14 +- .../tests/e2e/sharing/access-control.spec.ts | 4 +- 10 files changed, 354 insertions(+), 161 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7490c27f503..fa4edd688a1 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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}", diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index cc5584d615d..ec6e7690da2 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -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; } }; diff --git a/packages/frontend/editor-ui/src/features/core/folders/components/MoveToFolderDropdown.vue b/packages/frontend/editor-ui/src/features/core/folders/components/MoveToFolderDropdown.vue index 6f89fc3bdcc..93aaeef4c9c 100644 --- a/packages/frontend/editor-ui/src/features/core/folders/components/MoveToFolderDropdown.vue +++ b/packages/frontend/editor-ui/src/features/core/folders/components/MoveToFolderDropdown.vue @@ -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([]); -const moveFolderDropdown = ref>(); const selectedLocationId = computed({ get: () => props.selectedLocation?.id ?? null, set: (id) => { @@ -50,6 +49,7 @@ const selectedLocationId = computed({ }); 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) =