feat(editor): Add the incoming changes banner and apply entry point on the destination (no-changelog) (#39150)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Nour Alhadi Mahmoud 2026-09-22 12:11:14 +00:00 committed by GitHub
parent dda9a56cb7
commit 02cd44e3aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1238 additions and 175 deletions

View File

@ -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."
}

View File

@ -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', () => {

View File

@ -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);

View File

@ -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<IUser>({ 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<IUser>({ 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();

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useElementSize, useResizeObserver } from '@vueuse/core';
import type { TabOptions, UserAction } from '@n8n/design-system';
@ -12,7 +12,6 @@ import { getResourcePermissions } from '@n8n/permissions';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import ProjectCreateResource from './ProjectCreateResource.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { truncateTextToFitWidth } from '@/app/utils/formatters/textFormatter';
@ -23,19 +22,9 @@ import { useUIStore } from '@/app/stores/ui.store';
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
import { PROMOTION_SELECT_MODAL_KEY } from '@/features/integrations/promotions.ee/promotions.constants';
import { getPromotableChanges } from '@/features/integrations/promotions.ee/promotions.api';
import PromotionBanners from '@/features/integrations/promotions.ee/components/PromotionBanners.vue';
import {
N8nButton,
N8nHeading,
N8nIcon,
N8nIconButton,
N8nLink,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useCreateAgent } from '@/features/agents/composables/useCreateAgent';
@ -53,40 +42,11 @@ const telemetry = useTelemetry();
const { createAgent } = useCreateAgent();
const usersStore = useUsersStore();
const favoritesStore = useFavoritesStore();
const { isEnabled: isPromotionsEnabled } = usePromotionsEnabled();
const rootStore = useRootStore();
const currentProjectId = computed(() => projectsStore.currentProject?.id);
const isTeamProject = computed(() => projectsStore.currentProject?.type === ProjectTypes.Team);
const promotableChangeCount = ref(0);
const showPromoteButton = computed(
() =>
isTeamProject.value &&
isPromotionsEnabled.value &&
!!projectPermissions.value.export &&
!!getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection.push,
);
async function fetchPromotableChangeCount() {
// Capture the project this request is for, so a slow response for a project the
// user already navigated away from cannot overwrite the current count.
const requestedProjectId = currentProjectId.value;
promotableChangeCount.value = 0;
if (!showPromoteButton.value || !requestedProjectId) {
return;
}
try {
const { changes } = await getPromotableChanges(rootStore.restApiContext, requestedProjectId);
if (currentProjectId.value !== requestedProjectId) return;
promotableChangeCount.value = changes.length;
} catch {
if (currentProjectId.value !== requestedProjectId) return;
promotableChangeCount.value = 0;
}
}
const isProjectFavorited = computed(() =>
currentProjectId.value ? favoritesStore.isFavorite(currentProjectId.value, 'project') : false,
);
@ -176,21 +136,21 @@ const showFolders = computed(() => {
);
});
const customProjectTabs = computed((): Array<TabOptions<string>> => {
// Determine the type of tab based on the current project page
let tabType: 'shared' | 'overview' | 'project';
const pageType = computed(() => {
if (projectPages.isSharedSubPage) {
tabType = 'shared';
return 'shared';
} else if (projectPages.isOverviewSubPage) {
tabType = 'overview';
return 'overview';
} else {
tabType = 'project';
return 'project';
}
});
const customProjectTabs = computed((): Array<TabOptions<string>> => {
// Only pick up tabs from active modules
const activeModules = Object.keys(uiStore.moduleTabs[tabType]).filter(
settingsStore.isModuleActive,
);
return activeModules.flatMap((module) => uiStore.moduleTabs[tabType][module]);
const moduleTabs = uiStore.moduleTabs[pageType.value];
const activeModules = Object.keys(moduleTabs).filter(settingsStore.isModuleActive);
return activeModules.flatMap((module) => moduleTabs[module]);
});
const ACTION_TYPES = {
@ -430,16 +390,6 @@ const actions: Record<ActionTypes, (projectId: string, source: CreateSource) =>
},
} as const;
const pageType = computed(() => {
if (projectPages.isSharedSubPage) {
return 'shared';
} else if (projectPages.isOverviewSubPage) {
return 'overview';
} else {
return 'project';
}
});
const sectionDescription = computed(() => {
if (projectPages.isSharedSubPage) {
return i18n.baseText('projects.header.shared.subtitle');
@ -503,25 +453,6 @@ const projectDescriptionTruncated = computed(() => {
return truncateTextToFitWidth(projectDescription.value, availableTextWidth, fontSizeInPixels);
});
const promotionBannerText = computed(() => {
if (promotableChangeCount.value === 1) {
return i18n.baseText('promotions.banner.singleChangeAvailable');
}
return i18n.baseText('promotions.banner.changesAvailable', {
interpolate: { count: String(promotableChangeCount.value) },
});
});
watch([currentProjectId, showPromoteButton], fetchPromotableChangeCount, { immediate: true });
function onOpenPromotionModal() {
if (!currentProjectId.value) return;
uiStore.openModalWithData({
name: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: currentProjectId.value },
});
}
const onSelect = (action: string, source: CreateSource) => {
const executableAction = actions[action as ActionTypes];
if (!homeProject.value) {
@ -603,19 +534,7 @@ const onSelect = (action: string, source: CreateSource) => {
:additional-tabs="customProjectTabs"
/>
</div>
<div
v-if="showPromoteButton && promotableChangeCount > 0"
:class="$style.promotionBanner"
data-test-id="promotion-banner"
>
<N8nIcon icon="upload" size="small" />
<N8nText size="small">
{{ promotionBannerText }}
</N8nText>
<N8nLink size="small" data-test-id="promotion-banner-link" @click="onOpenPromotionModal">
{{ i18n.baseText('promotions.banner.viewChanges') }}
</N8nLink>
</div>
<PromotionBanners />
</div>
</template>
@ -638,17 +557,6 @@ const onSelect = (action: string, source: CreateSource) => {
padding: var(--spacing--2xs) 0 var(--spacing--xs);
}
.promotionBanner {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--xs) var(--spacing--sm);
background-color: var(--background--hover);
border: var(--border);
border-radius: var(--radius--2xs);
margin-bottom: var(--spacing--xs);
}
.projectDescriptionWrapper {
position: relative;
display: inline-block;

View File

@ -1,5 +1,8 @@
import { nextTick } from 'vue';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createProjectListItem, createTestProject } from '../__tests__/utils';
@ -136,6 +139,29 @@ describe('ProjectsNavigation', () => {
}).not.toThrow();
});
it('should reload the projects after a package was applied', async () => {
projectsStore.teamProjectsLimit = -1;
renderComponent({ props: { collapsed: false } });
// The listener registers once the users are fetched.
await waitFor(() => expect(usersStore.fetchUsers).toHaveBeenCalled());
await nextTick();
promotionEventBus.emit('applied', { projectId: 'project-1' });
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalled());
});
it('should reload the projects after a package removed one', async () => {
projectsStore.teamProjectsLimit = -1;
renderComponent({ props: { collapsed: false } });
await waitFor(() => expect(usersStore.fetchUsers).toHaveBeenCalled());
await nextTick();
promotionEventBus.emit('projectRemoved', { projectId: 'project-1' });
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalled());
});
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.myProjects = [...personalProjects, ...teamProjects];

View File

@ -2,6 +2,7 @@
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
import { VIEWS } from '@/app/constants';
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { N8nIcon, N8nMenuItem, N8nText } from '@n8n/design-system';
@ -186,18 +187,22 @@ const chat = computed<IMenuItem>(() => ({
route: { to: { name: CHAT_VIEW } },
}));
async function onSourceControlPull() {
// Update myProjects for the sidebar display
/** A pull or an applied package can create and delete projects behind the sidebar. */
async function reloadMyProjects() {
await projectsStore.getMyProjects();
}
onBeforeMount(async () => {
await usersStore.fetchUsers({ filter: { isPending: false }, take: 2 });
sourceControlEventBus.on('pull', onSourceControlPull);
sourceControlEventBus.on('pull', reloadMyProjects);
promotionEventBus.on('applied', reloadMyProjects);
promotionEventBus.on('projectRemoved', reloadMyProjects);
});
onBeforeUnmount(() => {
sourceControlEventBus.off('pull', onSourceControlPull);
sourceControlEventBus.off('pull', reloadMyProjects);
promotionEventBus.off('applied', reloadMyProjects);
promotionEventBus.off('projectRemoved', reloadMyProjects);
});
</script>

View File

@ -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', () => {

View File

@ -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(

View File

@ -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: '<div></div>' } },
{ path: '/home/workflows', name: VIEWS.HOMEPAGE, component: { template: '<div></div>' } },
],
});
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<string, unknown>) => ({
data: [{ id: 'connection-1', scope: 'instance', configs }],
nextCursor: null,
});
describe('PromotionBanners', () => {
let server: ReturnType<typeof createServer>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
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<IUser>({
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<IUser>({ 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<IUser>({
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<IUser>({
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<IUser>({ 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<IUser>({
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<IUser>({
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<IUser>({
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<IUser>({
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<IUser>({
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' }),
);
});
});

View File

@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
import { useUsersStore } from '@n8n/stores/users.store';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
import { PROMOTION_SELECT_MODAL_KEY } from '../promotions.constants';
import { promotionEventBus, type PromotionEventBusEvents } from '../promotions.eventBus';
import { useInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
import { usePromotionChangeCount } from '../composables/usePromotionChangeCount';
const i18n = useI18n();
const router = useRouter();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const { isEnabled: isPromotionsEnabled } = usePromotionsEnabled();
const currentProjectId = computed(() => projectsStore.currentProject?.id);
const isTeamProject = computed(() => projectsStore.currentProject?.type === ProjectTypes.Team);
const projectPermissions = computed(
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
);
const gitConnectionPermissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection,
);
// Each banner needs the direction configured on the instance connection, so a
// source never asks for incoming changes and a destination never asks for outgoing ones.
const {
connection,
hasPromoteConfig,
hasApplyConfig,
load: loadConnection,
} = useInstancePromotionConnection();
const canPreviewChanges = computed(
() =>
isTeamProject.value &&
isPromotionsEnabled.value &&
!!projectPermissions.value.export &&
!!gitConnectionPermissions.value.list &&
(!!gitConnectionPermissions.value.push || !!gitConnectionPermissions.value.pull),
);
const showPromoteBanner = computed(
() => canPreviewChanges.value && hasPromoteConfig.value && !!gitConnectionPermissions.value.push,
);
const showIncomingBanner = computed(
() => canPreviewChanges.value && hasApplyConfig.value && !!gitConnectionPermissions.value.pull,
);
watch(
[canPreviewChanges, currentProjectId],
async ([canPreview]) => {
if (canPreview) await loadConnection();
},
{ immediate: true },
);
const { count: promotableChangeCount, refetch: refetchPromotable } = usePromotionChangeCount(
currentProjectId,
'promote',
showPromoteBanner,
);
const {
count: incomingChangeCount,
failed: incomingCheckFailed,
refetch: refetchIncoming,
} = usePromotionChangeCount(currentProjectId, 'apply', showIncomingBanner);
// The store's project id follows the route at once, the current project only once it loaded,
// so a result for a project the user already left is dropped here.
async function onPromotionApplied({ projectId, project }: PromotionEventBusEvents['applied']) {
if (project && projectsStore.currentProjectId === projectId) {
projectsStore.setCurrentProject(project);
}
await Promise.all([refetchPromotable(), refetchIncoming()]);
}
async function onProjectRemoved({ projectId }: PromotionEventBusEvents['projectRemoved']) {
if (projectsStore.currentProjectId !== projectId) return;
await router.replace({ name: VIEWS.HOMEPAGE });
}
onMounted(() => {
promotionEventBus.on('applied', onPromotionApplied);
promotionEventBus.on('projectRemoved', onProjectRemoved);
});
onBeforeUnmount(() => {
promotionEventBus.off('applied', onPromotionApplied);
promotionEventBus.off('projectRemoved', onProjectRemoved);
});
const promotionBannerText = computed(() => {
if (promotableChangeCount.value === 1) {
return i18n.baseText('promotions.banner.singleChangeAvailable');
}
return i18n.baseText('promotions.banner.changesAvailable', {
interpolate: { count: String(promotableChangeCount.value) },
});
});
const incomingBannerText = computed(() => {
if (incomingCheckFailed.value) return i18n.baseText('promotions.banner.incoming.error');
if (incomingChangeCount.value === 1) {
return i18n.baseText('promotions.banner.incoming.singleChangeAvailable');
}
return i18n.baseText('promotions.banner.incoming.changesAvailable', {
interpolate: { count: String(incomingChangeCount.value) },
});
});
function onOpenPromotionModal() {
if (!currentProjectId.value) return;
uiStore.openModalWithData({
name: PROMOTION_SELECT_MODAL_KEY,
data: { projectId: currentProjectId.value, direction: 'promote' },
});
}
function onOpenIncomingModal() {
const applyConfig = connection.value?.configs.apply;
if (!currentProjectId.value || !connection.value || !applyConfig) return;
uiStore.openModalWithData({
name: PROMOTION_SELECT_MODAL_KEY,
data: {
projectId: currentProjectId.value,
direction: 'apply',
apply: {
connectionId: connection.value.id,
configId: applyConfig.id,
branchName: applyConfig.settings.branchName,
},
},
});
}
</script>
<template>
<div>
<div
v-if="showPromoteBanner && promotableChangeCount > 0"
:class="$style.banner"
data-test-id="promotion-banner"
>
<N8nIcon icon="upload" size="small" />
<N8nText size="small">
{{ promotionBannerText }}
</N8nText>
<N8nLink size="small" data-test-id="promotion-banner-link" @click="onOpenPromotionModal">
{{ i18n.baseText('promotions.banner.viewChanges') }}
</N8nLink>
</div>
<div
v-if="showIncomingBanner && (incomingChangeCount > 0 || incomingCheckFailed)"
:class="$style.banner"
data-test-id="promotion-incoming-banner"
>
<N8nIcon :icon="incomingCheckFailed ? 'triangle-alert' : 'download'" size="small" />
<N8nText size="small">
{{ incomingBannerText }}
</N8nText>
<N8nLink
size="small"
data-test-id="promotion-incoming-banner-link"
@click="onOpenIncomingModal"
>
{{ i18n.baseText('promotions.banner.viewChanges') }}
</N8nLink>
</div>
</div>
</template>
<style lang="scss" module>
.banner {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--xs) var(--spacing--sm);
background-color: var(--background--hover);
border: var(--border);
border-radius: var(--radius--2xs);
margin-bottom: var(--spacing--xs);
}
</style>

View File

@ -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<typeof vi.spyOn>;
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();
});
});
});

View File

@ -1,31 +1,51 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { ResponseError } from '@n8n/rest-api-client';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@n8n/composables/useToast';
import { MODAL_CONFIRM } from '@/app/constants/modals';
import Modal from '@/app/components/Modal.vue';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { N8nButton, N8nCheckbox, N8nInput, N8nText } from '@n8n/design-system';
import type { PromotableResourceStatus } from '@n8n/api-types';
import type { PromotableResourceStatus, PromotionDirection } from '@n8n/api-types';
import { usePromotionChanges } from '../composables/usePromotionChanges';
import { promotionEventBus } from '../promotions.eventBus';
import { applyPromotion } from '../promotionsSettings.api';
interface Props {
modalName: string;
data: {
projectId: string;
direction: PromotionDirection;
/** The instance connection and its Apply config. Only the `apply` direction needs it. */
apply?: { connectionId: string; configId: string; branchName: string };
};
}
const props = defineProps<Props>();
const projectsStore = useProjectsStore();
const i18n = useI18n();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const rootStore = useRootStore();
const message = useMessage();
const toast = useToast();
const modalBus = createEventBus();
const { direction } = props.data;
const isIncoming = direction === 'apply';
const isApplying = ref(false);
const {
changes,
commitSha,
filteredChanges,
isLoading,
error,
@ -37,7 +57,17 @@ const {
fetchChanges,
toggleSelected,
toggleSelectAll,
} = usePromotionChanges(props.data.projectId);
} = usePromotionChanges(props.data.projectId, direction);
const title = i18n.baseText(
isIncoming ? 'promotions.modal.incoming.title' : 'promotions.modal.title',
);
const emptyTitle = i18n.baseText(
isIncoming ? 'promotions.modal.incoming.empty' : 'promotions.modal.empty',
);
const emptyDescription = i18n.baseText(
isIncoming ? 'promotions.modal.incoming.empty.description' : 'promotions.modal.empty.description',
);
// No visible rows despite a loaded, non-empty change set means the search excluded everything.
const hasNoSearchResults = computed(
@ -92,6 +122,92 @@ async function onRefresh() {
await fetchChanges();
}
// The open views refresh once the outcome for this project is known, so none of them
// asks for a project the package removed.
async function announceApplied() {
const { projectId } = props.data;
try {
const project = await projectsStore.fetchProject(projectId);
promotionEventBus.emit('applied', { projectId, project });
} catch (error) {
if (error instanceof ResponseError && error.httpStatusCode === 404) {
toast.showMessage({
title: i18n.baseText('promotions.applied.projectRemoved'),
type: 'info',
});
promotionEventBus.emit('projectRemoved', { projectId });
return;
}
// The apply went through, so the views still refresh. Only the header keeps its name.
promotionEventBus.emit('applied', { projectId });
}
}
/** Applies the whole branch. The selection is kept for the selective apply that follows. */
async function onApplyAll() {
const { apply } = props.data;
if (!apply) return;
const confirmed = await message.confirm(
i18n.baseText('promotions.modal.incoming.confirm.message'),
i18n.baseText('promotions.modal.incoming.confirm.title'),
{
type: 'warning',
confirmButtonText: i18n.baseText('promotions.modal.incoming.confirm.confirmButtonText'),
cancelButtonText: i18n.baseText('promotions.modal.close'),
},
);
if (confirmed !== MODAL_CONFIRM) return;
isApplying.value = true;
try {
// Pin the reviewed commit: a branch that moved since the preview is reported, not applied.
const expectedSource = commitSha.value
? { configId: apply.configId, branchName: apply.branchName, commitSha: commitSha.value }
: undefined;
const result = await applyPromotion(
rootStore.publicApiContext,
apply.connectionId,
expectedSource && { expectedSource },
);
if (result.status === 'applied') {
const { workflows } = result.counts;
const notPublished = workflows.publishing.failed + workflows.publishing.blocked;
const summary = i18n.baseText('promotions.modal.incoming.applied.message', {
interpolate: {
created: String(workflows.created),
updated: String(workflows.updated),
archived: String(workflows.archived),
deleted: String(workflows.deleted),
},
});
// A workflow can be imported and still fail to publish, so success alone would mislead.
toast.showMessage({
title: i18n.baseText('promotions.modal.incoming.applied.title'),
message: notPublished
? `${summary} ${i18n.baseText('promotions.modal.incoming.applied.notPublished', {
interpolate: { count: String(notPublished) },
})}`
: summary,
type: notPublished ? 'warning' : 'success',
});
await announceApplied();
onClose();
return;
}
// Apply pauses on unresolved bindings or a moved source. The binding screen comes with LIGO-1058.
toast.showMessage({
title: i18n.baseText('promotions.modal.incoming.paused.title'),
message: i18n.baseText(`promotions.modal.incoming.paused.${result.status}`),
type: 'warning',
});
} catch (applyError) {
toast.showError(applyError, i18n.baseText('promotions.modal.incoming.applyError'));
} finally {
isApplying.value = false;
}
// The modal stays open after a paused or failed apply, so the list must show what is left.
await fetchChanges();
}
onMounted(async () => {
await fetchChanges();
});
@ -100,7 +216,7 @@ onMounted(async () => {
<template>
<Modal
:name="modalName"
:title="i18n.baseText('promotions.modal.title')"
:title="title"
:event-bus="modalBus"
width="640px"
height="80vh"
@ -162,10 +278,10 @@ onMounted(async () => {
<template v-else-if="changes.length === 0">
<div :class="$style.empty">
<N8nText size="medium" bold>
{{ i18n.baseText('promotions.modal.empty') }}
{{ emptyTitle }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('promotions.modal.empty.description') }}
{{ emptyDescription }}
</N8nText>
</div>
</template>
@ -248,7 +364,14 @@ onMounted(async () => {
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerLeft">
<N8nText size="small" color="text-light">
<N8nText v-if="isIncoming && selectedCount > 0" size="small" color="text-light">
{{
i18n.baseText('promotions.modal.incoming.selected', {
interpolate: { count: String(selectedCount) },
})
}}
</N8nText>
<N8nText v-else-if="!isIncoming" size="small" color="text-light">
{{ i18n.baseText('promotions.modal.previewOnly') }}
</N8nText>
</div>
@ -256,7 +379,16 @@ onMounted(async () => {
<N8nButton variant="subtle" @click="onClose">
{{ i18n.baseText('promotions.modal.close') }}
</N8nButton>
<N8nButton disabled data-test-id="promotion-submit">
<N8nButton
v-if="isIncoming"
:loading="isApplying"
:disabled="isLoading || !!error"
data-test-id="promotion-apply-all"
@click="onApplyAll"
>
{{ i18n.baseText('promotions.modal.incoming.applyAll') }}
</N8nButton>
<N8nButton v-else disabled data-test-id="promotion-submit">
{{ getPromoteButtonLabel() }}
</N8nButton>
</div>

View File

@ -0,0 +1,82 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
invalidateInstancePromotionConnection,
useInstancePromotionConnection,
} from './useInstancePromotionConnection';
import * as api from '../promotionsSettings.api';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ publicApiContext: {} }),
}));
vi.mock('../promotionsSettings.api');
const connection = {
id: 'connection-1',
scope: 'instance',
configs: { apply: { id: 'config-1' } },
} as unknown as api.PromotionConnection;
describe('useInstancePromotionConnection', () => {
beforeEach(() => {
vi.mocked(api.fetchPromotionConnections).mockReset();
invalidateInstancePromotionConnection();
});
it('should fetch the instance connection once for every caller', async () => {
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([connection]);
const header = useInstancePromotionConnection();
const modal = useInstancePromotionConnection();
await Promise.all([header.load(), modal.load()]);
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(1);
expect(api.fetchPromotionConnections).toHaveBeenCalledWith({}, { scope: 'instance' });
expect(modal.connection.value).toEqual(connection);
expect(header.hasApplyConfig.value).toBe(true);
expect(header.hasPromoteConfig.value).toBe(false);
});
it('should report no configured direction when the instance has no connection', async () => {
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([]);
const {
connection: current,
hasApplyConfig,
hasPromoteConfig,
load,
} = useInstancePromotionConnection();
await load();
expect(current.value).toBeNull();
expect(hasApplyConfig.value).toBe(false);
expect(hasPromoteConfig.value).toBe(false);
});
it('should treat a failed lookup as no connection and retry on the next load', async () => {
vi.mocked(api.fetchPromotionConnections)
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce([connection]);
const { connection: current, load } = useInstancePromotionConnection();
await load();
expect(current.value).toBeNull();
await load();
expect(current.value).toEqual(connection);
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(2);
});
it('should fetch again only after the cache is invalidated', async () => {
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([connection]);
const { load } = useInstancePromotionConnection();
await load();
await load();
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(1);
invalidateInstancePromotionConnection();
await load();
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,35 @@
import { computed, ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
fetchPromotionConnections,
type PromotionConnectionSummary,
} from '../promotionsSettings.api';
let instanceConnection: Promise<PromotionConnectionSummary | null> | undefined;
export function invalidateInstancePromotionConnection() {
instanceConnection = undefined;
}
/** The instance connection and which directions it has, so callers only ask for those. */
export function useInstancePromotionConnection() {
const rootStore = useRootStore();
const connection = ref<PromotionConnectionSummary | null>(null);
async function load() {
instanceConnection ??= fetchPromotionConnections(rootStore.publicApiContext, {
scope: 'instance',
})
.then((connections) => connections[0] ?? null)
.catch(() => {
invalidateInstancePromotionConnection();
return null;
});
connection.value = await instanceConnection;
}
const hasPromoteConfig = computed(() => connection.value?.configs.promote !== undefined);
const hasApplyConfig = computed(() => connection.value?.configs.apply !== undefined);
return { connection, hasPromoteConfig, hasApplyConfig, load };
}

View File

@ -0,0 +1,68 @@
import { nextTick, ref } from 'vue';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { PromotionChanges } from '@n8n/api-types';
import { waitAllPromises } from '@/__tests__/utils';
import { usePromotionChangeCount } from './usePromotionChangeCount';
import * as api from '../promotions.api';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('../promotions.api');
type Pending = {
resolve: (changes: PromotionChanges) => void;
reject: (error: Error) => void;
};
const changes = (count: number): PromotionChanges => ({
commitSha: 'a'.repeat(40),
changes: Array.from({ length: count }, (_, index) => ({
id: `workflow-${index}`,
name: `Workflow ${index}`,
type: 'workflow',
status: 'new',
version: null,
updatedAt: null,
updatedBy: null,
dependencyCount: 0,
})),
});
describe('usePromotionChangeCount', () => {
// One entry per request, in call order, so a test can answer them out of order.
let pending: Pending[];
beforeEach(() => {
pending = [];
vi.mocked(api.getPromotableChanges).mockImplementation(
async () =>
await new Promise<PromotionChanges>((resolve, reject) => {
pending.push({ resolve, reject });
}),
);
});
it('should keep the newest result when an older request answers last', async () => {
const projectId = ref<string | undefined>('project-a');
const { count, failed } = usePromotionChangeCount(projectId, 'apply', ref(true));
projectId.value = 'project-b';
await nextTick();
projectId.value = 'project-a';
await nextTick();
expect(api.getPromotableChanges).toHaveBeenCalledTimes(3);
pending[2].resolve(changes(2));
await waitAllPromises();
expect(count.value).toBe(2);
// The first request was for the same project, so a project id check alone would accept it.
pending[0].resolve(changes(5));
pending[1].reject(new Error('offline'));
await waitAllPromises();
expect(count.value).toBe(2);
expect(failed.value).toBe(false);
});
});

View File

@ -0,0 +1,44 @@
import { ref, watch, type Ref } from 'vue';
import type { PromotionDirection } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useLatestFetch } from '@/app/composables/useLatestFetch';
import { getPromotableChanges } from '../promotions.api';
/** How many rows the change preview lists for a project in one direction, while `enabled` holds. */
export function usePromotionChangeCount(
projectId: Ref<string | undefined>,
direction: PromotionDirection,
enabled: Ref<boolean>,
) {
const rootStore = useRootStore();
const { next } = useLatestFetch();
const count = ref(0);
/** True when the last check failed, so the banner can say so instead of hiding. */
const failed = ref(false);
async function fetchCount() {
// Only the newest request may write, so a slow answer for a project the user left
// and came back to cannot overwrite the current state.
const isLatest = next();
const requestedProjectId = projectId.value;
count.value = 0;
failed.value = false;
if (!enabled.value || !requestedProjectId) {
return;
}
try {
const { changes } = await getPromotableChanges(
rootStore.restApiContext,
requestedProjectId,
direction,
);
if (isLatest()) count.value = changes.length;
} catch {
if (isLatest()) failed.value = true;
}
}
watch([projectId, enabled], fetchCount, { immediate: true });
return { count, failed, refetch: fetchCount };
}

View File

@ -0,0 +1,9 @@
import { createEventBus } from '@n8n/utils/event-bus';
import type { Project } from '@/features/collaboration/projects/projects.types';
export interface PromotionEventBusEvents {
applied: { projectId: string; project?: Project };
projectRemoved: { projectId: string };
}
export const promotionEventBus = createEventBus<PromotionEventBusEvents>();

View File

@ -1,4 +1,6 @@
import type {
ApplyPackageDto,
ApplyPackageResultDto,
CreatePromotionConnectionDto,
CreatePromotionProviderDto,
PromotionApplyConfigPublicDto,
@ -223,3 +225,15 @@ export const promotePackage = async (
endpoint: `${promotionsApiRoot}/connections/${connectionId}/promote`,
data: payload,
});
export const applyPromotion = async (
context: PublicApiContext,
connectionId: string,
payload?: ApplyPackageDto,
): Promise<ApplyPackageResultDto> =>
await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply`,
data: payload,
});

View File

@ -19,6 +19,7 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import PromoteInstanceSection from '../components/PromoteInstanceSection.vue';
import PromotionConnectionForm from '../components/PromotionConnectionForm.vue';
import PromotionProviderDialog from '../components/PromotionProviderDialog.vue';
import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
import {
fetchPromotionConnection,
fetchPromotionConnections,
@ -117,6 +118,12 @@ function onProviderDeleted() {
pendingLoad = load();
}
function onConnectionSaved(saved: PromotionConnection) {
connection.value = saved;
// The project header caches the instance connection per page load.
invalidateInstancePromotionConnection();
}
async function focusProvider(id: string | undefined) {
await nextTick();
const row = id ? list.value?.querySelector<HTMLElement>(`[data-provider-id="${id}"]`) : undefined;
@ -207,7 +214,7 @@ async function onDialogOpenChange(open: boolean) {
ref="connectionForm"
:providers="providers"
:connection="connection"
@saved="connection = $event"
@saved="onConnectionSaved"
@add-provider="openCreateDialog(true)"
/>
</N8nSettingsSection>