diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts index de3e71aecd9..1f107dc1dfa 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts @@ -35,6 +35,7 @@ const connectedCredentials = ref>({}); const selectedCredentials = ref>({}); const loadingMap = ref>({}); const runtimeErrors = ref>({}); +const credentialModalOpen = ref(false); vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), @@ -168,7 +169,7 @@ vi.mock('../composables/useAgentChannelSetup', () => ({ selectedCredentials, credentialsLoading: ref(false), credentialPermissions: ref({ create: true }), - credentialModalOpen: ref(false), + credentialModalOpen, getChannelCredentialId: (type?: string | null) => type ? (selectedCredentials.value[type] ?? connectedCredentials.value[type] ?? '') : '', getCredentials: () => [ @@ -195,9 +196,19 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) { stubs: { Dialog: { props: ['open', 'showCloseButton'], - emits: ['update:open'], + emits: ['update:open', 'interactOutside'], + methods: { + // Mirrors reka-ui's own DismissableLayer: an outside click fires + // `interact-outside` first, and only dismisses if that event + // wasn't prevented. + clickOutside() { + const event = new Event('interact-outside', { cancelable: true }); + this.$emit('interactOutside', event); + if (!event.defaultPrevented) this.$emit('update:open', false); + }, + }, template: - '
', + '
', }, DialogHeader: { template: '
' }, DialogTitle: { template: '

' }, @@ -248,6 +259,7 @@ describe('AgentChannelModal', () => { selectedCredentials.value = {}; loadingMap.value = {}; runtimeErrors.value = {}; + credentialModalOpen.value = false; mocks.connect.mockImplementation(async (type: string, credentialId: string) => { statuses.value[type] = 'connected'; connectedCredentials.value[type] = credentialId; @@ -418,6 +430,48 @@ describe('AgentChannelModal', () => { }); }); + describe('outside click', () => { + it('closes the modal', async () => { + const wrapper = mountModal('list'); + await flushPromises(); + + await wrapper.get('[data-testid="click-outside"]').trigger('click'); + + expect(wrapper.emitted('update:open')).toEqual([[false]]); + }); + + it('is ignored while the nested credential modal is open', async () => { + credentialModalOpen.value = true; + const wrapper = mountModal('list'); + await flushPromises(); + + await wrapper.get('[data-testid="click-outside"]').trigger('click'); + + expect(wrapper.emitted('update:open')).toBeUndefined(); + }); + + it('is ignored while an action is in flight', async () => { + let release: () => void = () => {}; + mocks.ensureAgentPersisted.mockImplementation( + async () => + await new Promise((resolve) => { + release = resolve; + }), + ); + selectedCredentials.value.example = 'credential-new'; + const wrapper = mountModal('example_setup'); + await flushPromises(); + + await wrapper.get('[data-testid="connect-channel"]').trigger('click'); + await wrapper.get('[data-testid="click-outside"]').trigger('click'); + + expect(wrapper.emitted('update:open')).toBeUndefined(); + + release(); + await flushPromises(); + }); + }); + it('surfaces a failed pre-save step instead of connecting', async () => { mocks.beforeSave.mockRejectedValue(new Error('settings could not be saved')); selectedCredentials.value.example = 'credential-new'; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue index 0da6dd113ce..351a612bbed 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue @@ -333,6 +333,11 @@ function handleModalOpenUpdate(isOpen: boolean) { emit('update:open', isOpen); } +// Only block outside-close while the teleported credential modal is open. +function handleInteractOutside(event: Event) { + if (credentialModalOpen.value) event.preventDefault(); +} + async function persistAgent(): Promise { try { await props.ensureAgentPersisted?.(); @@ -525,7 +530,7 @@ watch( :trap-focus="!credentialModalOpen" :disable-outside-pointer-events="!credentialModalOpen" :show-close-button="false" - @interact-outside="(e) => e.preventDefault()" + @interact-outside="handleInteractOutside" @update:open="handleModalOpenUpdate" >