diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.test.ts index 549475af53348..eafabe6ea0565 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.test.ts @@ -242,4 +242,51 @@ describe('ProjectMoveResourceModal', () => { ['1', '2'], ); }); + + it('should prevent duplicate submissions when button clicked multiple times', async () => { + const destinationProject = createProjectListItem(); + projectsStore.availableProjects = [destinationProject]; + workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow()); + + // Make moveResourceToProject take time to simulate slow operation + let resolveMove: () => void; + const movePromise = new Promise((resolve) => { + resolveMove = resolve; + }); + projectsStore.moveResourceToProject.mockReturnValue(movePromise); + + const props: ComponentProps = { + modalName: PROJECT_MOVE_RESOURCE_MODAL, + data: { + resourceType: ResourceType.Workflow, + resourceTypeLabel: 'workflow', + resource: createTestWorkflow({ + id: '1', + name: 'My Workflow', + }), + }, + }; + + const { getByTestId } = renderComponent({ props }); + + // Select a project + const projectSelect = getByTestId('project-move-resource-modal-select'); + const projectSelectDropdownItems = await getDropdownItems(projectSelect); + await userEvent.click(projectSelectDropdownItems[0]); + + const moveButton = getByTestId('project-move-resource-modal-button'); + expect(moveButton).toBeEnabled(); + + // Click the button multiple times rapidly + await userEvent.click(moveButton); + await userEvent.click(moveButton); + await userEvent.click(moveButton); + + // Should only be called once due to loading state guard + expect(projectsStore.moveResourceToProject).toHaveBeenCalledTimes(1); + + // Resolve the operation + resolveMove!(); + await movePromise; + }); }); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.vue index 0905db5299f71..aa107d837d860 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMoveResourceModal.vue @@ -28,6 +28,7 @@ import { sortByProperty } from '@n8n/utils/sort/sortByProperty'; import { truncate } from '@n8n/utils/string/truncate'; import { computed, h, onMounted, ref } from 'vue'; import { I18nT } from 'vue-i18n'; +import { useRouter } from 'vue-router'; import { N8nButton, @@ -52,6 +53,7 @@ const props = defineProps<{ const i18n = useI18n(); const uiStore = useUIStore(); const toast = useToast(); +const router = useRouter(); const projectsStore = useProjectsStore(); const workflowsStore = useWorkflowsStore(); const credentialsStore = useCredentialsStore(); @@ -62,6 +64,7 @@ const projectId = ref(null); const shareUsedCredentials = ref(false); const usedCredentials = ref([]); const allCredentials = ref([]); +const loading = ref(false); const shareableCredentials = computed(() => allCredentials.value.filter( (credential) => @@ -133,7 +136,9 @@ const setFilter = (query: string) => { }; const moveResource = async () => { - if (!selectedProject.value) return; + if (!selectedProject.value || loading.value) return; + + loading.value = true; try { await projectsStore.moveResourceToProject( props.data.resourceType, @@ -163,6 +168,15 @@ const moveResource = async () => { areAllUsedCredentialsShareable: shareableCredentials.value.length === usedCredentials.value.length, }), + onClick: (event: MouseEvent | undefined) => { + if (event?.target instanceof HTMLAnchorElement && selectedProject.value) { + event.preventDefault(); + void router.push({ + name: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS, + params: { projectId: selectedProject.value.id }, + }); + } + }, type: 'success', duration: 8000, }); @@ -175,7 +189,7 @@ const moveResource = async () => { } } catch (error) { toast.showError( - error.message, + error, i18n.baseText('projects.move.resource.error.title', { interpolate: { resourceTypeLabel: props.data.resourceTypeLabel, @@ -183,6 +197,8 @@ const moveResource = async () => { }, }), ); + } finally { + loading.value = false; } }; @@ -337,11 +353,12 @@ onMounted(async () => {