fix(editor): Close Workflow URL Import Modal after import (#15177)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Nikhil Kuriakose
2025-05-09 14:06:41 +02:00
committed by GitHub
parent 33030eae7d
commit d14fb4dde3
9 changed files with 214 additions and 30 deletions

View File

@@ -6,12 +6,10 @@ import {
MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME,
} from '../constants'; } from '../constants';
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
import { NDV } from '../pages/ndv'; import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass(); const WorkflowPage = new WorkflowPageClass();
const messageBox = new MessageBoxClass();
const ndv = new NDV(); const ndv = new NDV();
describe('Undo/Redo', () => { describe('Undo/Redo', () => {
@@ -256,11 +254,11 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.workflowMenuItemImportFromURLItem().should('be.visible'); WorkflowPage.getters.workflowMenuItemImportFromURLItem().should('be.visible');
WorkflowPage.getters.workflowMenuItemImportFromURLItem().click(); WorkflowPage.getters.workflowMenuItemImportFromURLItem().click();
// Try while prompt is open // Try while prompt is open
messageBox.getters.header().click(); WorkflowPage.getters.inputURLImportWorkflowFromURL().click();
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1); WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
// Close prompt and try again // Close prompt and try again
messageBox.actions.cancel(); WorkflowPage.getters.cancelActionImportWorkflowFromURL().click();
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0); WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
}); });

View File

@@ -1,9 +1,7 @@
import { WorkflowPage } from '../pages'; import { WorkflowPage } from '../pages';
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
import { errorToast, successToast } from '../pages/notifications'; import { errorToast, successToast } from '../pages/notifications';
const workflowPage = new WorkflowPage(); const workflowPage = new WorkflowPage();
const messageBox = new MessageBoxClass();
before(() => { before(() => {
cy.fixture('Onboarding_workflow.json').then((data) => { cy.fixture('Onboarding_workflow.json').then((data) => {
@@ -20,11 +18,13 @@ describe('Import workflow', () => {
workflowPage.getters.workflowMenu().click(); workflowPage.getters.workflowMenu().click();
workflowPage.getters.workflowMenuItemImportFromURLItem().click(); workflowPage.getters.workflowMenuItemImportFromURLItem().click();
messageBox.getters.modal().should('be.visible'); workflowPage.getters.inputURLImportWorkflowFromURL().should('be.visible');
messageBox.getters.content().type('https://fakepage.com/workflow.json'); workflowPage.getters
.inputURLImportWorkflowFromURL()
.type('https://fakepage.com/workflow.json');
messageBox.getters.confirm().click(); workflowPage.getters.confirmActionImportWorkflowFromURL().click();
workflowPage.actions.zoomToFit(); workflowPage.actions.zoomToFit();
@@ -37,7 +37,6 @@ describe('Import workflow', () => {
it('clicking outside modal should not show error toast', () => { it('clicking outside modal should not show error toast', () => {
workflowPage.actions.visit(true); workflowPage.actions.visit(true);
workflowPage.getters.workflowMenu().click(); workflowPage.getters.workflowMenu().click();
workflowPage.getters.workflowMenuItemImportFromURLItem().click(); workflowPage.getters.workflowMenuItemImportFromURLItem().click();
@@ -51,7 +50,7 @@ describe('Import workflow', () => {
workflowPage.getters.workflowMenu().click(); workflowPage.getters.workflowMenu().click();
workflowPage.getters.workflowMenuItemImportFromURLItem().click(); workflowPage.getters.workflowMenuItemImportFromURLItem().click();
messageBox.getters.cancel().click(); workflowPage.getters.cancelActionImportWorkflowFromURL().click();
errorToast().should('not.exist'); errorToast().should('not.exist');
}); });

View File

@@ -219,6 +219,9 @@ export class WorkflowPage extends BasePage {
} }
return parseFloat(element.css('top')); return parseFloat(element.css('top'));
}, },
inputURLImportWorkflowFromURL: () => cy.getByTestId('workflow-url-import-input'),
cancelActionImportWorkflowFromURL: () => cy.getByTestId('cancel-workflow-import-url-button'),
confirmActionImportWorkflowFromURL: () => cy.getByTestId('confirm-workflow-import-url-button'),
confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'), confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'),
}; };

View File

@@ -0,0 +1,88 @@
import { createComponentRenderer } from '@/__tests__/render';
import ImportWorkflowUrlModal from './ImportWorkflowUrlModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { useUIStore } from '@/stores/ui.store';
import { nodeViewEventBus } from '@/event-bus';
import { IMPORT_WORKFLOW_URL_MODAL_KEY } from '@/constants';
import userEvent from '@testing-library/user-event';
const ModalStub = {
template: `
<div>
<slot name="header" />
<slot name="title" />
<slot name="content" />
<slot name="footer" />
</div>
`,
};
const initialState = {
modalsById: {
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: true,
},
},
modalStack: [IMPORT_WORKFLOW_URL_MODAL_KEY],
};
const global = {
stubs: {
Modal: ModalStub,
},
};
const renderModal = createComponentRenderer(ImportWorkflowUrlModal);
let pinia: ReturnType<typeof createTestingPinia>;
describe('ImportWorkflowUrlModal', () => {
beforeEach(() => {
pinia = createTestingPinia({ initialState });
});
it('should close the modal on cancel', async () => {
const { getByTestId } = renderModal({
global,
pinia,
});
const uiStore = useUIStore();
await userEvent.click(getByTestId('cancel-workflow-import-url-button'));
expect(uiStore.closeModal).toHaveBeenCalledWith(IMPORT_WORKFLOW_URL_MODAL_KEY);
});
it('should emit importWorkflowUrl event on confirm', async () => {
const { getByTestId } = renderModal({
global,
pinia,
});
const urlInput = getByTestId('workflow-url-import-input');
const confirmButton = getByTestId('confirm-workflow-import-url-button');
await userEvent.type(urlInput, 'https://valid-url.com/workflow.json');
expect(confirmButton).toBeEnabled();
const emitSpy = vi.spyOn(nodeViewEventBus, 'emit');
await userEvent.click(confirmButton);
expect(emitSpy).toHaveBeenCalledWith('importWorkflowUrl', {
url: 'https://valid-url.com/workflow.json',
});
});
it('should disable confirm button for invalid URL', async () => {
const { getByTestId } = renderModal({
global,
pinia,
});
const urlInput = getByTestId('workflow-url-import-input');
const confirmButton = getByTestId('confirm-workflow-import-url-button');
await userEvent.type(urlInput, 'invalid-url');
expect(confirmButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import { nodeViewEventBus } from '@/event-bus';
import { VALID_WORKFLOW_IMPORT_URL_REGEX, IMPORT_WORKFLOW_URL_MODAL_KEY } from '@/constants';
const i18n = useI18n();
const uiStore = useUIStore();
const url = ref('');
const inputRef = ref<HTMLInputElement | null>(null);
const isValid = computed(() => {
return url.value ? VALID_WORKFLOW_IMPORT_URL_REGEX.test(url.value) : true;
});
const closeModal = () => {
uiStore.closeModal(IMPORT_WORKFLOW_URL_MODAL_KEY);
};
const confirm = () => {
nodeViewEventBus.emit('importWorkflowUrl', { url: url.value });
closeModal();
};
const focusInput = async () => {
if (inputRef.value) {
inputRef.value.focus();
}
};
</script>
<template>
<Modal
:name="IMPORT_WORKFLOW_URL_MODAL_KEY"
:title="i18n.baseText('mainSidebar.prompt.importWorkflowFromUrl')"
:show-close="true"
:center="true"
width="420px"
@opened="focusInput"
>
<template #content>
<div :class="$style.noScrollbar">
<n8n-input
ref="inputRef"
v-model="url"
:placeholder="i18n.baseText('mainSidebar.prompt.workflowUrl')"
:state="isValid ? 'default' : 'error'"
data-test-id="workflow-url-import-input"
@keyup.enter="confirm"
/>
<p :class="$style['error-text']" :style="{ visibility: isValid ? 'hidden' : 'visible' }">
{{ i18n.baseText('mainSidebar.prompt.invalidUrl') }}
</p>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button
type="primary"
float="right"
:disabled="!url || !isValid"
data-test-id="confirm-workflow-import-url-button"
@click="confirm"
>
{{ i18n.baseText('mainSidebar.prompt.import') }}
</n8n-button>
<n8n-button
type="secondary"
float="right"
data-test-id="cancel-workflow-import-url-button"
@click="closeModal"
>
{{ i18n.baseText('mainSidebar.prompt.cancel') }}
</n8n-button>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.error-text {
color: var(--color-danger);
font-size: var(--font-size-2xs);
margin-top: var(--spacing-2xs);
height: var(--spacing-s);
visibility: hidden;
}
.footer {
> * {
margin-left: var(--spacing-3xs);
}
}
.noScrollbar {
overflow: hidden;
}
</style>

View File

@@ -6,11 +6,11 @@ import {
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
VALID_WORKFLOW_IMPORT_URL_REGEX,
VIEWS, VIEWS,
WORKFLOW_MENU_ACTIONS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import ShortenName from '@/components/ShortenName.vue'; import ShortenName from '@/components/ShortenName.vue';
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue'; import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
@@ -476,24 +476,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
break; break;
} }
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: { case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
try { uiStore.openModal(IMPORT_WORKFLOW_URL_MODAL_KEY);
const promptResponse = await message.prompt(
locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
{
confirmButtonText: locale.baseText('mainSidebar.prompt.import'),
cancelButtonText: locale.baseText('mainSidebar.prompt.cancel'),
inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: VALID_WORKFLOW_IMPORT_URL_REGEX,
},
);
if (promptResponse.action === 'cancel') {
return;
}
nodeViewEventBus.emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
break; break;
} }
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: { case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: {

View File

@@ -37,6 +37,7 @@ import {
MOVE_FOLDER_MODAL_KEY, MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY, WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY, FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import AboutModal from '@/components/AboutModal.vue'; import AboutModal from '@/components/AboutModal.vue';
@@ -75,6 +76,7 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue'; import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue'; import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
import FromAiParametersModal from '@/components/FromAiParametersModal.vue'; import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
import ImportWorkflowUrlModal from '@/components/ImportWorkflowUrlModal.vue';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
</script> </script>
@@ -124,6 +126,10 @@ import type { EventBus } from '@n8n/utils/event-bus';
</template> </template>
</ModalRoot> </ModalRoot>
<ModalRoot :name="IMPORT_WORKFLOW_URL_MODAL_KEY">
<ImportWorkflowUrlModal />
</ModalRoot>
<ModalRoot :name="PERSONALIZATION_MODAL_KEY"> <ModalRoot :name="PERSONALIZATION_MODAL_KEY">
<PersonalizationModal /> <PersonalizationModal />
</ModalRoot> </ModalRoot>

View File

@@ -51,6 +51,7 @@ export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
export const DELETE_USER_MODAL_KEY = 'deleteUser'; export const DELETE_USER_MODAL_KEY = 'deleteUser';
export const INVITE_USER_MODAL_KEY = 'inviteUser'; export const INVITE_USER_MODAL_KEY = 'inviteUser';
export const DUPLICATE_MODAL_KEY = 'duplicate'; export const DUPLICATE_MODAL_KEY = 'duplicate';
export const IMPORT_WORKFLOW_URL_MODAL_KEY = 'importWorkflowUrl';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager'; export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager'; export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
export const VERSIONS_MODAL_KEY = 'versions'; export const VERSIONS_MODAL_KEY = 'versions';

View File

@@ -41,6 +41,7 @@ import {
MOVE_FOLDER_MODAL_KEY, MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY, WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY, FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import type { import type {
INodeUi, INodeUi,
@@ -126,6 +127,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
SETUP_CREDENTIALS_MODAL_KEY, SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL, PROJECT_MOVE_RESOURCE_MODAL,
NEW_ASSISTANT_SESSION_MODAL, NEW_ASSISTANT_SESSION_MODAL,
IMPORT_WORKFLOW_URL_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]), ].map((modalKey) => [modalKey, { open: false }]),
), ),
[DELETE_USER_MODAL_KEY]: { [DELETE_USER_MODAL_KEY]: {
@@ -199,6 +201,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
nodeName: undefined, nodeName: undefined,
}, },
}, },
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: false,
data: {
url: '',
},
},
}); });
const modalStack = ref<string[]>([]); const modalStack = ref<string[]>([]);