Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
resolveMove = resolve;
});
projectsStore.moveResourceToProject.mockReturnValue(movePromise);

const props: ComponentProps<typeof ProjectMoveResourceModal> = {
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;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -62,6 +64,7 @@ const projectId = ref<string | null>(null);
const shareUsedCredentials = ref(false);
const usedCredentials = ref<IUsedCredential[]>([]);
const allCredentials = ref<ICredentialsResponse[]>([]);
const loading = ref(false);
const shareableCredentials = computed(() =>
allCredentials.value.filter(
(credential) =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -163,6 +168,15 @@ const moveResource = async () => {
areAllUsedCredentialsShareable:
shareableCredentials.value.length === usedCredentials.value.length,
}),
onClick: (event: MouseEvent | undefined) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using onClick because RouterLink doesn't work in the toast (Also see change in the ProjectMoveSuccessToastMessage component)

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,
});
Expand All @@ -175,14 +189,16 @@ const moveResource = async () => {
}
} catch (error) {
toast.showError(
error.message,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what was causing the error modal to show undefined

error,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: resourceName.value,
},
}),
);
} finally {
loading.value = false;
}
};

Expand Down Expand Up @@ -337,11 +353,12 @@ onMounted(async () => {
</template>
<template #footer>
<div :class="$style.buttons">
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
<N8nButton type="secondary" text class="mr-2xs" :disabled="loading" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
:disabled="!projectId"
:loading="loading"
:disabled="!projectId || loading"
type="primary"
data-test-id="project-move-resource-modal-button"
@click="moveResource"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,14 @@ const targetProjectName = computed(() => {
<span v-else>{{ i18n.baseText('projects.move.resource.success.message.workflow') }}</span>
</N8nText>
<p v-if="isTargetProjectTeam" class="pt-s">
<RouterLink
:to="{
name: props.routeName,
params: { projectId: props.targetProject.id },
}"
>
<!-- The navigation should be handled by the component showing the toast -->
<a href="#">
{{
i18n.baseText('projects.move.resource.success.link', {
interpolate: { targetProjectName },
})
}}
</RouterLink>
</a>
</p>
</div>
</template>
Loading