From 02cd44e3aa48d887673f91710ec24da94b3bd93e Mon Sep 17 00:00:00 2001 From: Nour Alhadi Mahmoud Date: Tue, 22 Sep 2026 12:11:14 +0000 Subject: [PATCH] feat(editor): Add the incoming changes banner and apply entry point on the destination (no-changelog) (#39150) Co-authored-by: Claude Fable 5.1 --- .../frontend/@n8n/i18n/src/locales/en.json | 21 +- .../src/app/views/WorkflowsView.test.ts | 18 +- .../editor-ui/src/app/views/WorkflowsView.vue | 8 + .../projects/components/ProjectHeader.test.ts | 42 --- .../projects/components/ProjectHeader.vue | 120 +------- .../components/ProjectNavigation.test.ts | 26 ++ .../projects/components/ProjectNavigation.vue | 13 +- .../core/dataTable/DataTableView.test.ts | 15 +- .../features/core/dataTable/DataTableView.vue | 8 +- .../components/PromotionBanners.test.ts | 291 ++++++++++++++++++ .../components/PromotionBanners.vue | 192 ++++++++++++ .../components/PromotionSelectModal.test.ts | 250 ++++++++++++++- .../components/PromotionSelectModal.vue | 148 ++++++++- .../useInstancePromotionConnection.test.ts | 82 +++++ .../useInstancePromotionConnection.ts | 35 +++ .../usePromotionChangeCount.test.ts | 68 ++++ .../composables/usePromotionChangeCount.ts | 44 +++ .../promotions.ee/promotions.eventBus.ts | 9 + .../promotions.ee/promotionsSettings.api.ts | 14 + .../views/PromotionsSettingsView.vue | 9 +- 20 files changed, 1238 insertions(+), 175 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/useInstancePromotionConnection.test.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/useInstancePromotionConnection.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.eventBus.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 30d1e666f75..186ff7456c5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8830,5 +8830,24 @@ "promotions.modal.promoteSingle": "Promote 1 change", "promotions.banner.changesAvailable": "{count} changes available to promote.", "promotions.banner.singleChangeAvailable": "1 change available to promote.", - "promotions.banner.viewChanges": "View changes" + "promotions.banner.viewChanges": "View changes", + "promotions.banner.incoming.changesAvailable": "{count} incoming changes.", + "promotions.banner.incoming.singleChangeAvailable": "1 incoming change.", + "promotions.banner.incoming.error": "Could not check for incoming changes.", + "promotions.modal.incoming.title": "Incoming changes", + "promotions.modal.incoming.empty": "Nothing to apply", + "promotions.modal.incoming.empty.description": "This project matches the source.", + "promotions.modal.incoming.selected": "{count} selected", + "promotions.modal.incoming.applyAll": "Apply all changes", + "promotions.modal.incoming.confirm.title": "Apply remote changes to this instance?", + "promotions.modal.incoming.confirm.message": "Apply overwrites every team project on this instance to match the source, and deletes projects and content that are not in the package.", + "promotions.modal.incoming.confirm.confirmButtonText": "Apply", + "promotions.modal.incoming.applied.title": "Changes applied", + "promotions.modal.incoming.applied.message": "{created} created, {updated} updated, {archived} archived, {deleted} deleted.", + "promotions.modal.incoming.applied.notPublished": "{count} could not be published.", + "promotions.modal.incoming.applyError": "Could not apply the changes", + "promotions.modal.incoming.paused.title": "Apply paused", + "promotions.modal.incoming.paused.blocked": "Some credentials or variables are not set up on this instance yet. Nothing was changed.", + "promotions.modal.incoming.paused.source-changed": "The source changed since this preview. Refresh and review the changes again.", + "promotions.applied.projectRemoved": "Apply removed this project from the instance." } diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts index 3c4023d7573..1763f67b680 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts @@ -11,6 +11,7 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; +import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus'; import { useTagsStore } from '@/features/shared/tags/tags.store'; import { useUsersStore } from '@n8n/stores/users.store'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; @@ -53,8 +54,9 @@ vi.mock('@n8n/composables/useTelemetry', () => ({ })); const mockShowError = vi.fn(); +const mockShowMessage = vi.fn(); vi.mock('@n8n/composables/useToast', () => ({ - useToast: () => ({ showError: mockShowError }), + useToast: () => ({ showError: mockShowError, showMessage: mockShowMessage }), })); const router = createRouter({ @@ -466,6 +468,20 @@ describe('WorkflowsView', () => { await sourceControl.pullWorkfolder(true, 'none'); }); }); + + describe('promotions', () => { + it('should reload the list after a package was applied', async () => { + renderComponent({ pinia }); + await waitAllPromises(); + const fetches = workflowsListStore.fetchWorkflowsPage.mock.calls.length; + + promotionEventBus.emit('applied', { projectId: 'project-1' }); + + await waitFor(() => + expect(workflowsListStore.fetchWorkflowsPage.mock.calls.length).toBeGreaterThan(fetches), + ); + }); + }); }); describe('Folders', () => { diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index 7b6a153284a..a93b347c92c 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -70,6 +70,7 @@ import { WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT } from '@/app/constants/experiments import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; +import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus'; import { useTagsStore } from '@/features/shared/tags/tags.store'; import { useUIStore } from '@/app/stores/ui.store'; import { useUsageStore } from '@/features/settings/usage/usage.store'; @@ -668,6 +669,11 @@ sourceControlStore.$onAction(({ name, after }) => { after(async () => await initialize()); }); +/** The project header leaves the page when the package removed this project. */ +async function onPromotionApplied() { + await initialize(); +} + const refreshWorkflows = async () => { await Promise.all([ fetchWorkflows(), @@ -751,9 +757,11 @@ onMounted(async () => { workflowListEventBus.on('folder-transferred', onFolderTransferred); workflowListEventBus.on('workflow-moved', onWorkflowMoved); workflowListEventBus.on('workflow-transferred', onWorkflowTransferred); + promotionEventBus.on('applied', onPromotionApplied); }); onBeforeUnmount(() => { + promotionEventBus.off('applied', onPromotionApplied); workflowListEventBus.off('resource-moved', fetchWorkflows); workflowListEventBus.off('workflow-duplicated', fetchWorkflows); workflowListEventBus.off('folder-deleted', onFolderDeleted); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts index ddedca35aa3..bfab0847655 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts @@ -17,7 +17,6 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useUsersStore } from '@n8n/stores/users.store'; import { mock } from 'vitest-mock-extended'; import type { IUser } from '@n8n/rest-api-client'; -import { createServer } from 'miragejs'; const mockPush = vi.fn(); vi.mock('vue-router', async () => { @@ -116,47 +115,6 @@ describe('ProjectHeader', () => { vi.clearAllMocks(); }); - it('shows live promotion changes only with export and push access', async () => { - const server = createServer({ environment: 'test' }); - server.get('/rest/promotions/project-1/changes/promote', () => ({ - data: { - commitSha: 'a'.repeat(40), - changes: [ - { id: 'workflow-1', name: 'Changed workflow', type: 'workflow', status: 'modified' }, - ], - }, - })); - try { - settingsStore.isModuleActive.mockReturnValue(true); - settingsStore.settings = { - ...settingsStore.settings, - envFeatureFlags: { N8N_ENV_FEAT_PROMOTIONS: 'true' }, - }; - projectsStore.currentProject = createTestProject({ - id: 'project-1', - scopes: ['project:export'], - }); - usersStore.currentUser = mock({ globalScopes: ['gitConnection:push'] }); - const { findByTestId, queryByTestId } = renderComponent(); - - expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change'); - await userEvent.click(await findByTestId('promotion-banner-link')); - expect(uiStore.openModalWithData).toHaveBeenCalledWith({ - name: 'promotionSelect', - data: { projectId: 'project-1' }, - }); - - projectsStore.currentProject.scopes = []; - await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument()); - projectsStore.currentProject.scopes = ['project:export']; - expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change'); - usersStore.currentUser = mock({ globalScopes: [] }); - await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument()); - } finally { - server.shutdown(); - } - }); - it('should not render title icon on overview page', async () => { vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true); const { container } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue index e7a32a60ed4..91b45edf6a4 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue @@ -1,5 +1,5 @@ diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.test.ts b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.test.ts index 86db17ce000..b91608c43c8 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.test.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.test.ts @@ -1,10 +1,11 @@ import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore, waitAllPromises } from '@/__tests__/utils'; -import { fireEvent } from '@testing-library/vue'; +import { fireEvent, waitFor } from '@testing-library/vue'; import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import DataTableView from '@/features/core/dataTable/DataTableView.vue'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; +import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus'; import { STORES } from '@n8n/stores'; import { createTestingPinia } from '@pinia/testing'; import { createRouter, createWebHistory } from 'vue-router'; @@ -190,6 +191,18 @@ describe('DataTableView', () => { expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error loading data tables'); }); + + it('should reload the data tables after a package was applied', async () => { + renderComponent({ pinia }); + await waitAllPromises(); + const fetches = dataTableStore.fetchDataTables.mock.calls.length; + + promotionEventBus.emit('applied', { projectId: 'test-project' }); + + await waitFor(() => + expect(dataTableStore.fetchDataTables.mock.calls.length).toBeGreaterThan(fetches), + ); + }); }); describe('empty state', () => { diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue index b26815ae401..42dbede79be 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue @@ -18,9 +18,10 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useDataTableStore } from '@/features/core/dataTable/dataTable.store'; import type { DataTableResource } from '@/features/core/dataTable/types'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; +import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus'; import type { BaseFilters, SortingAndPaginationUpdates } from '@/Interface'; import { useI18n } from '@n8n/i18n'; -import { computed, onMounted, ref, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue'; @@ -169,6 +170,11 @@ const onSearchUpdated = async (search: string) => { onMounted(() => { documentTitle.set(i18n.baseText('dataTable.dataTables')); + promotionEventBus.on('applied', fetchDataTables); +}); + +onBeforeUnmount(() => { + promotionEventBus.off('applied', fetchDataTables); }); watch( diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts new file mode 100644 index 00000000000..f4008fe360f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts @@ -0,0 +1,291 @@ +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; +import { createServer, Response } from 'miragejs'; +import { mock } from 'vitest-mock-extended'; +import { createRouter, createWebHistory } from 'vue-router'; +import type { IUser } from '@n8n/rest-api-client'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { useUsersStore } from '@n8n/stores/users.store'; + +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore, waitAllPromises } from '@/__tests__/utils'; +import { VIEWS } from '@/app/constants'; +import { useUIStore } from '@/app/stores/ui.store'; +import { createTestProject } from '@/features/collaboration/projects/__tests__/utils'; +import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; + +import PromotionBanners from './PromotionBanners.vue'; +import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection'; +import { promotionEventBus } from '../promotions.eventBus'; +import * as settingsApi from '../promotionsSettings.api'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/:projectId?', component: { template: '
' } }, + { path: '/home/workflows', name: VIEWS.HOMEPAGE, component: { template: '
' } }, + ], +}); + +const renderComponent = createComponentRenderer(PromotionBanners, { + global: { plugins: [router] }, +}); + +const commitSha = 'a'.repeat(40); + +const oneChange = (name: string, status: string) => ({ + data: { commitSha, changes: [{ id: 'workflow-1', name, type: 'workflow', status }] }, +}); + +const connections = (configs: Record) => ({ + data: [{ id: 'connection-1', scope: 'instance', configs }], + nextCursor: null, +}); + +describe('PromotionBanners', () => { + let server: ReturnType; + let projectsStore: ReturnType>; + let settingsStore: ReturnType>; + let usersStore: ReturnType>; + let uiStore: ReturnType>; + + beforeEach(async () => { + await router.push('/project-1'); + await router.isReady(); + createTestingPinia(); + server = createServer({ environment: 'test' }); + projectsStore = mockedStore(useProjectsStore); + settingsStore = mockedStore(useSettingsStore); + usersStore = mockedStore(useUsersStore); + uiStore = mockedStore(useUIStore); + + settingsStore.isModuleActive.mockReturnValue(true); + settingsStore.settings = { + ...settingsStore.settings, + envFeatureFlags: { N8N_ENV_FEAT_PROMOTIONS: 'true' }, + }; + projectsStore.currentProject = createTestProject({ + id: 'project-1', + scopes: ['project:export'], + }); + // The store reads this from the route, which the mocked store cannot see. + projectsStore.currentProjectId = 'project-1'; + // The connection is cached per page load; tests must not share it. + invalidateInstancePromotionConnection(); + }); + + afterEach(() => { + server.shutdown(); + vi.clearAllMocks(); + }); + + it('shows outgoing changes only with export and push access', async () => { + server.get('/api/v1/promotions/connections', () => + connections({ promote: { id: 'config-1' } }), + ); + server.get('/rest/promotions/project-1/changes/promote', () => + oneChange('Changed workflow', 'modified'), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:push'], + }); + const { findByTestId, queryByTestId } = renderComponent(); + + expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change'); + await userEvent.click(await findByTestId('promotion-banner-link')); + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: 'promotionSelect', + data: { projectId: 'project-1', direction: 'promote' }, + }); + + // The store replaces the project object, so the change is reactive. + projectsStore.currentProject = createTestProject({ id: 'project-1', scopes: [] }); + await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument()); + projectsStore.currentProject = createTestProject({ + id: 'project-1', + scopes: ['project:export'], + }); + expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change'); + // Listing connections alone is not enough, the direction needs its own scope. + usersStore.currentUser = mock({ globalScopes: ['gitConnection:list'] }); + await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument()); + }); + + it('shows nothing while the promotion feature is off', async () => { + const connectionsRequest = vi.fn(() => + connections({ + promote: { id: 'config-1' }, + apply: { id: 'config-2', settings: { branchName: 'main' } }, + }), + ); + server.get('/api/v1/promotions/connections', connectionsRequest); + settingsStore.settings = { ...settingsStore.settings, envFeatureFlags: {} }; + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:push', 'gitConnection:pull'], + }); + const { queryByTestId } = renderComponent(); + + await waitFor(() => expect(connectionsRequest).not.toHaveBeenCalled()); + expect(queryByTestId('promotion-banner')).not.toBeInTheDocument(); + expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument(); + }); + + it('shows incoming changes only with pull access and an apply configuration', async () => { + const promoteChanges = vi.fn(() => ({ data: { commitSha, changes: [] } })); + server.get('/api/v1/promotions/connections', () => + connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }), + ); + server.get('/rest/promotions/project-1/changes/promote', promoteChanges); + server.get('/rest/promotions/project-1/changes/apply', () => + oneChange('Incoming workflow', 'new'), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:pull'], + }); + const { findByTestId, queryByTestId } = renderComponent(); + + expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent('1 incoming change'); + expect(queryByTestId('promotion-banner')).not.toBeInTheDocument(); + // A destination without a promote configuration never asks for outgoing changes. + expect(promoteChanges).not.toHaveBeenCalled(); + + await userEvent.click(await findByTestId('promotion-incoming-banner-link')); + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: 'promotionSelect', + data: { + projectId: 'project-1', + direction: 'apply', + apply: { connectionId: 'connection-1', configId: 'config-1', branchName: 'main' }, + }, + }); + + usersStore.currentUser = mock({ globalScopes: ['gitConnection:list'] }); + await waitFor(() => expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument()); + }); + + it('refetches both counts after a package was applied', async () => { + const promoteChanges = vi.fn(() => oneChange('Outgoing workflow', 'modified')); + const applyChanges = vi.fn(() => oneChange('Incoming workflow', 'new')); + server.get('/api/v1/promotions/connections', () => + connections({ + promote: { id: 'config-1' }, + apply: { id: 'config-2', settings: { branchName: 'main' } }, + }), + ); + server.get('/rest/promotions/project-1/changes/promote', promoteChanges); + server.get('/rest/promotions/project-1/changes/apply', applyChanges); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:push', 'gitConnection:pull'], + }); + const { findByTestId } = renderComponent(); + await findByTestId('promotion-banner'); + await findByTestId('promotion-incoming-banner'); + + const renamed = createTestProject({ id: 'project-1', name: 'Renamed' }); + promotionEventBus.emit('applied', { projectId: 'project-1', project: renamed }); + + await waitFor(() => expect(promoteChanges).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2)); + // The header shows the project name, which the package may have changed. + expect(projectsStore.setCurrentProject).toHaveBeenCalledWith(renamed); + }); + + it('leaves the page when the applied package removed the project', async () => { + server.get('/api/v1/promotions/connections', () => + connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }), + ); + server.get('/rest/promotions/project-1/changes/apply', () => + oneChange('Incoming workflow', 'new'), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:pull'], + }); + const { findByTestId } = renderComponent(); + await findByTestId('promotion-incoming-banner'); + + promotionEventBus.emit('projectRemoved', { projectId: 'project-1' }); + + await waitFor(() => expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE)); + }); + + it('ignores results for a project the user already left', async () => { + server.get('/api/v1/promotions/connections', () => + connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }), + ); + server.get('/rest/promotions/:projectId/changes/apply', () => + oneChange('Incoming workflow', 'new'), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:pull'], + }); + const { findByTestId } = renderComponent(); + await findByTestId('promotion-incoming-banner'); + // The route moves at once, the store still holds the old project until it loaded. + await router.push('/project-2'); + projectsStore.currentProjectId = 'project-2'; + + promotionEventBus.emit('applied', { + projectId: 'project-1', + project: createTestProject({ id: 'project-1', name: 'Renamed' }), + }); + promotionEventBus.emit('projectRemoved', { projectId: 'project-1' }); + await waitAllPromises(); + + expect(projectsStore.setCurrentProject).not.toHaveBeenCalled(); + expect(router.currentRoute.value.params.projectId).toBe('project-2'); + }); + + it('retries a failed connection lookup on the next project', async () => { + const lookup = vi + .spyOn(settingsApi, 'fetchPromotionConnections') + .mockRejectedValueOnce(new Error('offline')); + const connectionsRequest = vi.fn(() => + connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }), + ); + server.get('/api/v1/promotions/connections', connectionsRequest); + server.get('/rest/promotions/project-2/changes/apply', () => + oneChange('Incoming workflow', 'new'), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:pull'], + }); + const { findByTestId, queryByTestId } = renderComponent(); + await waitFor(() => expect(lookup).toHaveBeenCalledTimes(1)); + // Let the rejection settle, a switch while the lookup is in flight joins the same request. + await waitAllPromises(); + expect(connectionsRequest).not.toHaveBeenCalled(); + expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument(); + + projectsStore.currentProject = createTestProject({ + id: 'project-2', + scopes: ['project:export'], + }); + + expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent('1 incoming change'); + expect(connectionsRequest).toHaveBeenCalledTimes(1); + }); + + it('keeps the incoming changes entry visible when the check fails', async () => { + server.get('/api/v1/promotions/connections', () => + connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }), + ); + server.get( + '/rest/promotions/project-1/changes/apply', + () => new Response(400, {}, { message: 'The apply direction is not cloned' }), + ); + usersStore.currentUser = mock({ + globalScopes: ['gitConnection:list', 'gitConnection:pull'], + }); + const { findByTestId } = renderComponent(); + + expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent( + 'Could not check for incoming changes', + ); + // The modal shows the error and offers a retry. + await userEvent.click(await findByTestId('promotion-incoming-banner-link')); + expect(uiStore.openModalWithData).toHaveBeenCalledWith( + expect.objectContaining({ name: 'promotionSelect' }), + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue new file mode 100644 index 00000000000..a9f2336c8e6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts index 926ba260526..8f7df8d7495 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts @@ -1,12 +1,33 @@ import { createTestingPinia } from '@pinia/testing'; import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { createTestProject } from '@/features/collaboration/projects/__tests__/utils'; +import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import PromotionSelectModal from './PromotionSelectModal.vue'; import { PROMOTION_SELECT_MODAL_KEY } from '../promotions.constants'; +import { promotionEventBus } from '../promotions.eventBus'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { createServer, Response } from 'miragejs'; +import { createServer, Response, type Request } from 'miragejs'; +import { ResponseError } from '@n8n/rest-api-client'; import { useUsersStore } from '@n8n/stores/users.store'; +import { useUIStore } from '@/app/stores/ui.store'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/vue'; +import { MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants/modals'; + +const { confirm, showMessage, showError } = vi.hoisted(() => ({ + confirm: vi.fn(), + showMessage: vi.fn(), + showError: vi.fn(), +})); + +vi.mock('@/app/composables/useMessage', () => ({ + useMessage: () => ({ confirm }), +})); + +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showMessage, showError }), +})); const mockChanges = [ { @@ -81,7 +102,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -98,7 +119,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -111,7 +132,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -128,7 +149,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -146,7 +167,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -160,7 +181,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -192,7 +213,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -214,7 +235,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -256,7 +277,7 @@ describe('PromotionSelectModal', () => { pinia, props: { modalName: PROMOTION_SELECT_MODAL_KEY, - data: { projectId: 'project-1' }, + data: { projectId: 'project-1', direction: 'promote' }, }, }); @@ -265,4 +286,213 @@ describe('PromotionSelectModal', () => { expect(statuses).toEqual(['Will be archived', 'Will be deleted']); }); }); + + describe('apply direction', () => { + const applyProps = { + modalName: PROMOTION_SELECT_MODAL_KEY, + data: { + projectId: 'project-1', + direction: 'apply' as const, + apply: { connectionId: 'connection-1', configId: 'config-1', branchName: 'main' }, + }, + }; + const applyChanges = vi.fn(() => ({ data: changesBody(mockChanges) })); + const publishing = { published: 2, unpublished: 0, unchanged: 0, blocked: 0, failed: 0 }; + const applyPackage = vi.fn((_schema: unknown, request: Request) => ({ + receivedBody: JSON.parse(request.requestBody), + connectionId: 'connection-1', + configId: 'config-1', + status: 'applied', + counts: { workflows: { created: 1, updated: 1, archived: 0, deleted: 0, publishing } }, + warnings: [], + git: { commitSha: 'a'.repeat(40), branchName: 'main' }, + })); + + let applied: ReturnType; + + beforeEach(() => { + server.get('/rest/promotions/project-1/changes/apply', applyChanges); + server.post('/api/v1/promotions/connections/connection-1/apply', applyPackage); + confirm.mockResolvedValue(MODAL_CONFIRM); + applied = vi.spyOn(promotionEventBus, 'emit'); + }); + + it('should offer to apply all changes instead of promoting', async () => { + const { findByTestId, queryByTestId } = renderComponent({ pinia, props: applyProps }); + + expect(await findByTestId('promotion-apply-all')).toHaveTextContent('Apply all changes'); + expect(queryByTestId('promotion-submit')).not.toBeInTheDocument(); + expect(applyChanges).toHaveBeenCalledTimes(1); + }); + + it('should not apply when the user cancels the confirmation', async () => { + confirm.mockResolvedValue(MODAL_CANCEL); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(applyPackage).not.toHaveBeenCalled(); + }); + + it('should apply the branch, report the counts and close the modal', async () => { + const project = createTestProject({ id: 'project-1', name: 'Renamed' }); + mockedStore(useProjectsStore).fetchProject.mockResolvedValue(project); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => expect(applyPackage).toHaveBeenCalledTimes(1)); + // The reviewed commit travels with the request, so a moved branch is not applied blindly. + expect(applyPackage.mock.results[0].value.receivedBody).toEqual({ + expectedSource: { configId: 'config-1', branchName: 'main', commitSha: 'a'.repeat(40) }, + }); + await waitFor(() => + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + message: '1 created, 1 updated, 0 archived, 0 deleted.', + }), + ), + ); + await waitFor(() => + expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY), + ); + // A closed modal has no list to refresh; the open views learn about the apply instead. + expect(applyChanges).toHaveBeenCalledTimes(1); + expect(applied).toHaveBeenCalledWith('applied', { projectId: 'project-1', project }); + }); + + it('should warn when applied workflows could not be published', async () => { + server.post('/api/v1/promotions/connections/connection-1/apply', () => ({ + connectionId: 'connection-1', + configId: 'config-1', + status: 'applied', + counts: { + workflows: { + created: 2, + updated: 0, + archived: 0, + deleted: 0, + publishing: { ...publishing, published: 0, blocked: 1, failed: 1 }, + }, + }, + warnings: [], + git: { commitSha: 'a'.repeat(40), branchName: 'main' }, + })); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + title: 'Changes applied', + message: '2 created, 0 updated, 0 archived, 0 deleted. 2 could not be published.', + }), + ), + ); + // The import went through, so the views still refresh and the modal closes. + expect(applied).toHaveBeenCalledWith( + 'applied', + expect.objectContaining({ projectId: 'project-1' }), + ); + await waitFor(() => + expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY), + ); + }); + + it('should announce a removed project instead of an applied one', async () => { + mockedStore(useProjectsStore).fetchProject.mockRejectedValue( + new ResponseError('Not found', { httpStatusCode: 404 }), + ); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => + expect(applied).toHaveBeenCalledWith('projectRemoved', { projectId: 'project-1' }), + ); + expect(applied).not.toHaveBeenCalledWith('applied', expect.anything()); + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Apply removed this project from the instance.', + type: 'info', + }), + ); + await waitFor(() => + expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY), + ); + }); + + it('should still announce the apply when the project lookup fails', async () => { + mockedStore(useProjectsStore).fetchProject.mockRejectedValue(new Error('offline')); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => + expect(applied).toHaveBeenCalledWith('applied', { projectId: 'project-1' }), + ); + }); + + it.each([ + { + result: { status: 'blocked', preflight: {} }, + message: + 'Some credentials or variables are not set up on this instance yet. Nothing was changed.', + }, + { + result: { status: 'source-changed' }, + message: 'The source changed since this preview. Refresh and review the changes again.', + }, + ])( + 'should warn and keep the modal open when apply pauses with $result.status', + async ({ result, message }) => { + server.post('/api/v1/promotions/connections/connection-1/apply', () => ({ + connectionId: 'connection-1', + configId: 'config-1', + git: { commitSha: 'a'.repeat(40), branchName: 'main' }, + ...result, + })); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning', title: 'Apply paused', message }), + ), + ); + expect(showMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2)); + expect(useUIStore().closeModal).not.toHaveBeenCalled(); + expect(applied).not.toHaveBeenCalled(); + }, + ); + + it('should show the error and still refetch the changes when apply fails', async () => { + server.post( + '/api/v1/promotions/connections/connection-1/apply', + () => new Response(409, {}, { message: 'Bindings unresolved' }), + ); + const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps }); + await findByText('Payment Handler'); + + await userEvent.click(await findByTestId('promotion-apply-all')); + + await waitFor(() => + expect(showError).toHaveBeenCalledWith(expect.anything(), 'Could not apply the changes'), + ); + await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2)); + expect(showMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue index 5cb6efbc456..55262d6ee08 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue @@ -1,31 +1,51 @@