feat(editor): Apply a selection of workflows at the destination (no-changelog) (#39788)

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Irénée
2026-10-08 10:41:39 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 370b30f919
commit aef216cb25
32 changed files with 1306 additions and 309 deletions
@@ -8,11 +8,19 @@ describe('PromotionChangesQueryDto', () => {
});
describe('PromotionChangesDto', () => {
const source = { configId: 'config-1', branchName: 'main' };
it('carries the commit the rows were read from, or null before the first commit', () => {
expect(PromotionChangesDto.safeParse({ commitSha: 'a'.repeat(40), changes: [] }).success).toBe(
expect(
PromotionChangesDto.safeParse({ commitSha: 'a'.repeat(40), source, changes: [] }).success,
).toBe(true);
expect(PromotionChangesDto.safeParse({ commitSha: null, source, changes: [] }).success).toBe(
true,
);
expect(PromotionChangesDto.safeParse({ commitSha: null, changes: [] }).success).toBe(true);
expect(PromotionChangesDto.safeParse({ changes: [] }).success).toBe(false);
expect(PromotionChangesDto.safeParse({ source, changes: [] }).success).toBe(false);
});
it('requires the source the rows were read from', () => {
expect(PromotionChangesDto.safeParse({ commitSha: null, changes: [] }).success).toBe(false);
});
});
@@ -39,6 +39,7 @@ export type PromotableResource = z.infer<typeof promotableResourceSchema>;
export const promotionChangesSchema = z.object({
commitSha: z.string().nullable(),
source: z.object({ configId: n8nIdSchema, branchName: z.string() }),
changes: promotableResourceSchema.array(),
});
@@ -40,6 +40,7 @@ import { createVariable } from '@test-integration/db/variables';
import { initNodeTypes, setupTestServer } from '@test-integration/utils';
import { PromotionConfigRepository } from '../database/repositories/promotion-config.repository';
import { PromotionConnectionProjectRepository } from '../database/repositories/promotion-connection-project.repository';
import { PromotionConnectionRepository } from '../database/repositories/promotion-connection.repository';
import { PromotionProviderRepository } from '../database/repositories/promotion-provider.repository';
import { PromotionsService } from '../promotions.service';
@@ -60,6 +61,7 @@ const server = setupTestServer({
let remoteFolder: string;
beforeEach(async () => {
await Container.get(PromotionConnectionProjectRepository).delete({});
await Container.get(PromotionConfigRepository).delete({});
await Container.get(PromotionConnectionRepository).delete({});
await Container.get(PromotionProviderRepository).delete({});
@@ -134,6 +136,48 @@ async function remoteHead(): Promise<string> {
return (await simpleGit(remoteFolder).revparse(['main'])).trim();
}
/** The source a preview reports for a connection created by `createConnection`. */
async function sourceOf(connectionId: string, direction: PromotionDirection) {
const config = await Container.get(PromotionConfigRepository).findByConnectionAndDirection(
connectionId,
direction,
);
return { configId: config?.id, branchName: 'main' };
}
it('previews against the connection linked to the project, not the instance one', async () => {
const owner = await createOwner();
const linked = await createTeamProject('Linked', owner);
const other = await createTeamProject('Other', owner);
const instance = await createConnection();
const projectConnection = await Container.get(PromotionConnectionRepository).insertConnection({
name: 'Project',
scope: 'projects',
providerId: instance.providerId,
target: { schemaVersion: 1, remoteUrl: remoteFolder },
});
const projectConfig = await Container.get(PromotionConfigRepository).insertConfig({
connectionId: projectConnection.id,
direction: 'promote',
name: 'Promote',
settings: { schemaVersion: 1, baseBranchName: 'production', createBranchOnPromotion: false },
});
await Container.get(PromotionConnectionProjectRepository).linkProject(
linked.id,
projectConnection.id,
);
await Container.get(PromotionsService).clone(projectConnection.id, 'promote');
const agent = server.authAgentFor(owner);
const linkedPreview = await agent.get(`/promotions/${linked.id}/changes/promote`).expect(200);
expect(linkedPreview.body.data.source).toEqual({
configId: projectConfig.id,
branchName: 'production',
});
const otherPreview = await agent.get(`/promotions/${other.id}/changes/promote`).expect(200);
expect(otherPreview.body.data.source).toEqual(await sourceOf(instance.id, 'promote'));
}, 30_000);
it('lists new, changed, moved, archived, restored and deleted workflows through the endpoint', async () => {
const owner = await createOwner();
const project = await createTeamProject('Preview', owner);
@@ -162,6 +206,7 @@ it('lists new, changed, moved, archived, restored and deleted workflows through
});
expect((await agent.get(endpoint).expect(200)).body.data).toEqual({
commitSha: await remoteHead(),
source: await sourceOf(connection.id, 'promote'),
changes: [],
});
@@ -466,7 +511,11 @@ it('lists what applying the branch changes on this instance, named and archived
const applyEndpoint = `/promotions/${project.id}/changes/apply`;
const synced = await agent.get(applyEndpoint).expect(200);
expect(synced.body.data).toEqual({ commitSha: baseline, changes: [] });
expect(synced.body.data).toEqual({
commitSha: baseline,
source: await sourceOf(connection.id, 'apply'),
changes: [],
});
await workflows.update(renamed.id, { name: 'Local name', settings: { executionOrder: 'v1' } });
await workflows.update(archived.id, { isArchived: false });
@@ -525,7 +574,11 @@ it('lists what applying the branch changes on this instance, named and archived
canExportVariableValues: true,
});
const after = await agent.get(applyEndpoint).expect(200);
expect(after.body.data).toEqual({ commitSha: await remoteHead(), changes: [] });
expect(after.body.data).toEqual({
commitSha: await remoteHead(),
source: await sourceOf(connection.id, 'apply'),
changes: [],
});
expect(after.body.data.commitSha).not.toBe(baseline);
}, 30_000);
@@ -608,6 +661,7 @@ it('answers for a destination that has only an apply configuration', async () =>
const response = await agent.get(applyEndpoint).expect(200);
expect(response.body.data).toEqual({
commitSha: await remoteHead(),
source: await sourceOf(connection.id, 'apply'),
changes: [expect.objectContaining({ id: workflow.id, name: 'Local only', status: 'new' })],
});
await agent.get(endpoint).expect(400);
@@ -1120,7 +1120,7 @@ describe('PromotionsService', () => {
],
}),
);
expect(branch.commitSha).toBe(commitSha);
expect(branch).toMatchObject({ configId: CONFIG_ID, branchName: 'dev', commitSha });
expect(branch.files.map(({ entityId, type }) => ({ entityId, type }))).toEqual([
{ entityId: 'p1', type: 'project' },
{ entityId: 'w1', type: 'workflow' },
@@ -1143,7 +1143,12 @@ describe('PromotionsService', () => {
const branch = await service.readBranchPackage('p1', 'apply');
expect(branch).toMatchObject({ commitSha: null, files: [] });
expect(branch).toMatchObject({
configId: CONFIG_ID,
branchName: 'dev',
commitSha: null,
files: [],
});
await expect(branch.readFiles(['n8n-export/manifest.json'])).rejects.toThrow(
'no exported package',
);
@@ -132,7 +132,11 @@ export class PromotionChangeService {
);
const rows = buildPromotableResources({ ...diff, base, archiveState, metadata });
return { commitSha: branch.commitSha, changes: applyQuery(rows, query) };
return {
commitSha: branch.commitSha,
source: { configId: branch.configId, branchName: branch.branchName },
changes: applyQuery(rows, query),
};
}
/** One route serves both directions, so the per-direction scope is checked here, not on the decorator. */
@@ -735,6 +735,8 @@ export class PromotionsService {
});
return {
configId: input.configId,
branchName,
commitSha,
files: parseBaseBranchFiles(lsTreeOutput, { exportRoot: PACKAGE_SUBFOLDER, projectId }),
readFiles: async (filePaths) => {
@@ -71,6 +71,8 @@ export type PromotionOperationInput = Readonly<{
* repository path, so the listing and the contents never describe two commits.
*/
export type BranchPackage = Readonly<{
configId: string;
branchName: string;
commitSha: string | null;
files: PackageFile[];
readFiles: (paths: readonly string[]) => Promise<Map<string, string>>;
@@ -58,6 +58,18 @@ responses:
commitSha:
type: string
nullable: true
source:
type: object
properties:
configId:
type: string
minLength: 1
maxLength: 36
branchName:
type: string
required:
- configId
- branchName
changes:
type: array
items:
@@ -106,6 +118,7 @@ responses:
- dependencyCount
required:
- commitSha
- source
- changes
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
@@ -684,9 +684,10 @@ describe('Promotions in Public API', () => {
describe('change preview', () => {
it('checks the scope of the requested direction and forwards the query', async () => {
const source = { configId: 'config1', branchName: 'main' };
const getChanges = vi
.spyOn(Container.get(PromotionChangeService), 'getChanges')
.mockResolvedValue({ commitSha: 'a'.repeat(40), changes: [] });
.mockResolvedValue({ commitSha: 'a'.repeat(40), source, changes: [] });
try {
const pushOnly = testServer.publicApiAgentFor(
await createOwnerWithApiKey({ scopes: ['gitConnection:push'] }),
@@ -697,7 +698,7 @@ describe('Promotions in Public API', () => {
const promote = await pushOnly.get('/promotions/projects/proj1/changes/promote');
expect(promote.status, JSON.stringify(promote.body)).toBe(200);
expect(promote.body).toEqual({ commitSha: 'a'.repeat(40), changes: [] });
expect(promote.body).toEqual({ commitSha: 'a'.repeat(40), source, changes: [] });
expect(getChanges).toHaveBeenLastCalledWith(
expect.objectContaining({ id: expect.any(String) }),
'proj1',
@@ -3901,6 +3901,8 @@
"settings.chatHub.label.credential": "Credential",
"settings.promotions.title": "Environments v2",
"settings.promotions.description": "Connect this instance to another environment so your workflows can be promoted.",
"settings.promotions.instanceActions.title": "Promote & apply",
"settings.promotions.instanceActions.description": "Move all projects between this instance and your connected Git remote.",
"settings.promotions.promote.title": "Promote instance",
"settings.promotions.promote.description": "Push all projects on this instance to your connected Git remote.",
"settings.promotions.promote.button": "Promote",
@@ -3915,6 +3917,21 @@
"settings.promotions.promote.toast.success.message": "Pushed to {branch}.",
"settings.promotions.promote.toast.error": "Couldn't promote the instance",
"settings.promotions.promote.notConnected": "Connect the promote branch in the instance connection below before you promote.",
"settings.promotions.apply.title": "Apply to instance",
"settings.promotions.apply.description": "Overwrite all projects on this instance to match your connected Git remote.",
"settings.promotions.apply.button": "Apply",
"settings.promotions.apply.dialog.title": "Apply to instance",
"settings.promotions.apply.dialog.ariaDescription": "Overwrite all projects on this instance to match the {branch} branch.",
"settings.promotions.apply.dialog.body": "This overwrites every project on this instance to match the {branch} branch, and deletes projects and content that are not in the package.",
"settings.promotions.apply.dialog.confirm": "Apply",
"settings.promotions.apply.toast.success.title": "Instance updated",
"settings.promotions.apply.toast.success.message": "Projects: {projectsCreated} created, {projectsDeleted} deleted. Workflows: {created} created, {updated} updated, {archived} archived, {deleted} deleted.",
"settings.promotions.apply.toast.success.messageNotPublished": "Projects: {projectsCreated} created, {projectsDeleted} deleted. Workflows: {created} created, {updated} updated, {archived} archived, {deleted} deleted. {notPublished} could not be published.",
"settings.promotions.apply.toast.sourceChanged.title": "Apply paused",
"settings.promotions.apply.toast.sourceChanged.message": "The {branch} branch changed before Apply finished. Start Apply again to use the latest version.",
"settings.promotions.apply.toast.error": "Couldn't apply to the instance",
"settings.promotions.apply.toast.error.message": "Some changes may already be applied. Check the instance before you try again.",
"settings.promotions.apply.notConnected": "Connect the apply branch in the instance connection below before you apply.",
"settings.promotions.providers.title": "Git providers",
"settings.promotions.providers.description": "A provider holds the credentials for a Git host. Connections share the provider.",
"settings.promotions.providers.add": "Add provider",
@@ -9350,13 +9367,14 @@
"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.applySelected": "Apply {count} changes",
"promotions.modal.incoming.applySelectedSingle": "Apply 1 change",
"promotions.modal.incoming.confirmSelected.title": "Apply the selected workflows?",
"promotions.modal.incoming.confirmSelected.message": "This applies only the selected workflows to this project. It does not change or remove anything else.",
"promotions.modal.incoming.confirmSelected.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.applied.messageNotPublished": "{created} created, {updated} updated, {archived} archived, {deleted} deleted. {notPublished} 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.",
@@ -0,0 +1,267 @@
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { defineComponent, h } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { applied, blocked, credential, variable } from '../__tests__/bindings.fixtures';
import type * as PromotionsApi from '../promotionsSettings.api';
import ApplyInstanceDialog from './ApplyInstanceDialog.vue';
const api = vi.hoisted(() => ({
applyPromotion: vi.fn<typeof PromotionsApi.applyPromotion>(),
continueApplyPromotion: vi.fn<typeof PromotionsApi.continueApplyPromotion>(),
continueApplyProjectSelection: vi.fn<typeof PromotionsApi.continueApplyProjectSelection>(),
}));
vi.mock('../promotionsSettings.api', () => api);
const mockShowError = vi.fn();
const mockShowMessage = vi.fn();
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError: mockShowError, showMessage: mockShowMessage }),
}));
const renderComponent = createComponentRenderer(ApplyInstanceDialog, {
props: { open: true, connectionId: 'connection-1', branchName: 'main' },
});
describe('ApplyInstanceDialog', () => {
beforeEach(() => {
vi.resetAllMocks();
createTestingPinia();
});
it('applies the whole branch and reports success', async () => {
// Everything published, so the report stays a success.
api.applyPromotion.mockResolvedValue({
...applied,
counts: {
...applied.counts,
projects: { created: 1, updated: 0, skipped: 0, deleted: 2 },
workflows: {
...applied.counts.workflows,
publishing: { published: 3, unpublished: 0, unchanged: 0, blocked: 0, failed: 0 },
},
},
});
const { findByTestId, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
// The whole-branch apply carries no expectedSource, so the branch tip is applied.
await waitFor(() =>
expect(api.applyPromotion).toHaveBeenCalledWith(expect.anything(), 'connection-1'),
);
// Project counts are reported too, because an instance apply can delete projects.
expect(mockShowMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
title: 'Instance updated',
message:
'Projects: 1 created, 2 deleted. Workflows: 1 created, 2 updated, 0 archived, 0 deleted.',
}),
);
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('reloads the project list after a successful apply', async () => {
api.applyPromotion.mockResolvedValue(applied);
const projectsStore = useProjectsStore();
const { findByTestId } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalledTimes(1));
expect(projectsStore.getProjectsCount).toHaveBeenCalledTimes(1);
// This user cannot list every project, so the full list is not loaded.
expect(projectsStore.getAllProjects).not.toHaveBeenCalled();
});
it('also reloads all projects when the user can list them', async () => {
api.applyPromotion.mockResolvedValue(applied);
const projectsStore = mockedStore(useProjectsStore);
projectsStore.globalProjectPermissions = { list: true };
const { findByTestId } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() => expect(projectsStore.getAllProjects).toHaveBeenCalledTimes(1));
expect(projectsStore.getMyProjects).toHaveBeenCalledTimes(1);
});
it('reports success when the project list cannot be reloaded', async () => {
api.applyPromotion.mockResolvedValue(applied);
const projectsStore = useProjectsStore();
vi.mocked(projectsStore.getMyProjects).mockRejectedValue(new Error('network'));
const { findByTestId, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalled());
expect(mockShowError).not.toHaveBeenCalled();
expect(mockShowMessage).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Instance updated' }),
);
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('continues the whole instance and reloads the project list after the binding flow applies', async () => {
api.applyPromotion.mockResolvedValue(blocked({ missingBindings: [credential] }));
const projectsStore = useProjectsStore();
const { findByTestId, emitted } = renderComponent({
global: {
stubs: {
PromotionBindingsFlow: defineComponent({
props: { continueWith: { type: Object, required: true } },
emits: ['applied', 'update:open'],
setup:
(props, { emit }) =>
() =>
h('button', {
'data-test-id': 'stub-applied',
'data-continue-kind': props.continueWith.kind,
// Same order as the bindings dialog after a successful Continue.
onClick: () => {
emit('applied', applied);
emit('update:open', false);
},
}),
}),
},
},
});
await userEvent.click(await findByTestId('apply-confirm-button'));
const flow = await findByTestId('stub-applied');
expect(flow).toHaveAttribute('data-continue-kind', 'instance');
await userEvent.click(flow);
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalledTimes(1));
expect(projectsStore.getProjectsCount).toHaveBeenCalledTimes(1);
expect(mockShowMessage).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Instance updated' }),
);
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('warns when applied workflows could not be published', async () => {
// The fixture imports two workflows that stayed blocked or failed to publish.
api.applyPromotion.mockResolvedValue(applied);
const { findByTestId, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() =>
expect(mockShowMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
title: 'Instance updated',
message:
'Projects: 0 created, 0 deleted. Workflows: 1 created, 2 updated, 0 archived, 0 deleted. 2 could not be published.',
}),
),
);
expect(mockShowMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('opens the whole-branch binding flow when apply is blocked', async () => {
api.applyPromotion.mockResolvedValue(blocked({ missingBindings: [credential, variable] }));
const { findByTestId, findByText, findAllByText, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await findByText('Resolve bindings');
await findAllByText(credential.name);
await findAllByText(variable.name);
// The flow stays open; no success reported and the dialog is not dismissed yet.
expect(mockShowMessage).not.toHaveBeenCalled();
expect(emitted('update:open')).toBeUndefined();
});
it('warns and closes when the source changed', async () => {
api.applyPromotion.mockResolvedValue({
status: 'source-changed',
connectionId: 'connection-1',
configId: 'config-apply',
git: { branchName: 'main', commitSha: 'b'.repeat(40) },
});
const { findByTestId, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() =>
expect(mockShowMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
title: 'Apply paused',
message:
'The main branch changed before Apply finished. Start Apply again to use the latest version.',
}),
),
);
expect(mockShowMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('shows an error and stays open when the apply fails', async () => {
const failure = new Error('git is unreachable');
api.applyPromotion.mockRejectedValueOnce(failure);
const { findByTestId, emitted } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() =>
expect(mockShowError).toHaveBeenCalledWith(failure, "Couldn't apply to the instance", {
message: 'Some changes may already be applied. Check the instance before you try again.',
}),
);
expect(mockShowMessage).not.toHaveBeenCalled();
expect(emitted('update:open')).toBeUndefined();
});
it('reloads the project list when the apply fails', async () => {
// A failed apply can still have created or deleted projects.
api.applyPromotion.mockRejectedValueOnce(new Error('import failed'));
const projectsStore = useProjectsStore();
const { findByTestId } = renderComponent();
await userEvent.click(await findByTestId('apply-confirm-button'));
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalledTimes(1));
expect(projectsStore.getProjectsCount).toHaveBeenCalledTimes(1);
});
it('reloads the project list when the binding flow closes without applying', async () => {
// A failed Continue keeps the flow open; the user then closes it.
api.applyPromotion.mockResolvedValue(blocked({ missingBindings: [credential] }));
const projectsStore = useProjectsStore();
const { findByTestId, emitted } = renderComponent({
global: {
stubs: {
PromotionBindingsFlow: defineComponent({
emits: ['update:open'],
setup:
(_, { emit }) =>
() =>
h('button', {
'data-test-id': 'stub-close',
onClick: () => emit('update:open', false),
}),
}),
},
},
});
await userEvent.click(await findByTestId('apply-confirm-button'));
await userEvent.click(await findByTestId('stub-close'));
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalledTimes(1));
expect(projectsStore.getProjectsCount).toHaveBeenCalledTimes(1);
expect(emitted('update:open')).toEqual([[false]]);
});
});
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nDialog,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { ref, shallowRef } from 'vue';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { applyPromotion } from '../promotionsSettings.api';
import PromotionBindingsFlow from './PromotionBindingsFlow.vue';
import type { AppliedResult, BlockedApplyResult } from '../promotions.types';
const props = defineProps<{
open: boolean;
connectionId: string;
branchName: string;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const isSubmitting = ref(false);
const blockedResult = shallowRef<BlockedApplyResult>();
function close() {
emit('update:open', false);
}
function onOpenChange(value: boolean) {
if (value || isSubmitting.value) return;
close();
}
function reportApplied(result: AppliedResult) {
const { projects, workflows } = result.counts;
const notPublished = workflows.publishing.failed + workflows.publishing.blocked;
const interpolate = {
projectsCreated: String(projects.created),
projectsDeleted: String(projects.deleted),
created: String(workflows.created),
updated: String(workflows.updated),
archived: String(workflows.archived),
deleted: String(workflows.deleted),
notPublished: String(notPublished),
};
// A workflow can be imported and still fail to publish, so success alone would mislead.
toast.showMessage({
title: i18n.baseText('settings.promotions.apply.toast.success.title'),
message: i18n.baseText(
notPublished
? 'settings.promotions.apply.toast.success.messageNotPublished'
: 'settings.promotions.apply.toast.success.message',
{ interpolate },
),
type: notPublished ? 'warning' : 'success',
});
}
// An instance apply can create and delete projects. The project sidebar is not mounted on
// this page, so reload the store it reads from instead of emitting a promotion event.
// The sidebar reads the user's own projects. Users who can list every project also read the
// full list, so reload that too.
async function refreshProjects() {
try {
await Promise.all([
projectsStore.getMyProjects(),
projectsStore.getProjectsCount(),
...(projectsStore.globalProjectPermissions.list ? [projectsStore.getAllProjects()] : []),
]);
} catch {
// The apply went through. A stale project list is fixed by the next page load.
}
}
async function onApplied(result: AppliedResult) {
reportApplied(result);
close();
await refreshProjects();
}
function reportSourceChanged() {
toast.showMessage({
title: i18n.baseText('settings.promotions.apply.toast.sourceChanged.title'),
message: i18n.baseText('settings.promotions.apply.toast.sourceChanged.message', {
interpolate: { branch: props.branchName },
}),
type: 'warning',
});
}
async function submit() {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
// Whole-branch apply: no expectedSource, so the current branch tip is applied.
const result = await applyPromotion(rootStore.publicApiContext, props.connectionId);
if (result.status === 'applied') {
await onApplied(result);
return;
}
if (result.status === 'blocked') {
// Hand off to the binding flow, which resumes the same whole-branch apply.
blockedResult.value = result;
return;
}
reportSourceChanged();
close();
} catch (error) {
// Apply can save some changes before a later step fails, so the project list can be stale.
toast.showError(error, i18n.baseText('settings.promotions.apply.toast.error'), {
message: i18n.baseText('settings.promotions.apply.toast.error.message'),
});
void refreshProjects();
} finally {
isSubmitting.value = false;
}
}
async function onBindingsApplied(result: AppliedResult) {
blockedResult.value = undefined;
await onApplied(result);
}
function onBindingsSourceChanged() {
blockedResult.value = undefined;
reportSourceChanged();
close();
}
function onBindingsOpenChange(open: boolean) {
// After a successful Continue, onBindingsApplied has already closed and refreshed.
if (open || !blockedResult.value) return;
blockedResult.value = undefined;
close();
// A failed Continue can leave part of the apply saved.
void refreshProjects();
}
</script>
<template>
<N8nDialog
v-if="!blockedResult"
:open="open"
size="medium"
:aria-description="
i18n.baseText('settings.promotions.apply.dialog.ariaDescription', {
interpolate: { branch: branchName },
})
"
data-test-id="apply-instance-dialog"
@update:open="onOpenChange"
>
<N8nDialogHeader>
<N8nDialogTitle>
{{ i18n.baseText('settings.promotions.apply.dialog.title') }}
</N8nDialogTitle>
</N8nDialogHeader>
<form :class="$style.form" @submit.prevent="submit">
<N8nText color="text-base">
{{
i18n.baseText('settings.promotions.apply.dialog.body', {
interpolate: { branch: branchName },
})
}}
</N8nText>
<N8nDialogFooter>
<N8nButton
type="button"
variant="outline"
:disabled="isSubmitting"
data-test-id="apply-cancel-button"
@click="close"
>
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton type="submit" :loading="isSubmitting" data-test-id="apply-confirm-button">
{{ i18n.baseText('settings.promotions.apply.dialog.confirm') }}
</N8nButton>
</N8nDialogFooter>
</form>
</N8nDialog>
<PromotionBindingsFlow
v-else
:open="true"
:blocked-result="blockedResult"
:continue-with="{ kind: 'instance' }"
@applied="onBindingsApplied"
@source-changed="onBindingsSourceChanged"
@update:open="onBindingsOpenChange"
/>
</template>
<style lang="scss" module>
.form {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
</style>
@@ -0,0 +1,145 @@
import { createTestingPinia } from '@pinia/testing';
import type { IUser } from '@n8n/rest-api-client';
import { useUsersStore } from '@n8n/stores/users.store';
import { mock } from 'vitest-mock-extended';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import type { PromotionConnection } from '../promotionsSettings.api';
import PromoteApplyInstanceSection from './PromoteApplyInstanceSection.vue';
const timestamps = {
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
};
const promoteConfig = (checkout = { hasCheckout: true, matchesConfig: true }) => ({
id: 'config-promote',
name: 'Promote',
settings: { schemaVersion: 1 as const, baseBranchName: 'main', createBranchOnPromotion: false },
checkout,
...timestamps,
});
const applyConfig = (checkout = { hasCheckout: true, matchesConfig: true }) => ({
id: 'config-apply',
name: 'Apply',
settings: { schemaVersion: 1 as const, branchName: 'main' },
checkout,
...timestamps,
});
const instanceConnection = (overrides: Partial<PromotionConnection> = {}): PromotionConnection =>
({
id: 'connection-1',
name: 'Production',
scope: 'instance',
target: { schemaVersion: 1, remoteUrl: '[email protected]:acme/workflows.git' },
provider: {
id: 'provider-ssh',
name: 'Production key',
type: 'git',
authType: 'ssh-key',
...timestamps,
},
configs: { promote: promoteConfig(), apply: applyConfig() },
...timestamps,
...overrides,
}) as PromotionConnection;
const renderComponent = createComponentRenderer(PromoteApplyInstanceSection);
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
describe('PromoteApplyInstanceSection', () => {
beforeEach(() => {
createTestingPinia();
usersStore = mockedStore(useUsersStore);
usersStore.currentUser = mock<IUser>({
globalScopes: ['gitConnection:push', 'gitConnection:pull'],
});
});
it('shows both actions in one section when both configs and scopes are present', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection() },
});
expect(getByTestId('promote-instance-button')).toBeEnabled();
expect(getByTestId('apply-instance-button')).toBeEnabled();
expect(queryByTestId('promote-instance-not-connected')).toBeNull();
expect(queryByTestId('apply-instance-not-connected')).toBeNull();
});
it('shows only the promote action when apply is unavailable', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection({ configs: { promote: promoteConfig() } }) },
});
expect(getByTestId('promote-instance-button')).toBeInTheDocument();
expect(queryByTestId('apply-instance-button')).toBeNull();
});
it('shows only the apply action when promote is unavailable', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection({ configs: { apply: applyConfig() } }) },
});
expect(getByTestId('apply-instance-button')).toBeInTheDocument();
expect(queryByTestId('promote-instance-button')).toBeNull();
});
it('disables each action and explains why when its checkout is not connected', () => {
const { getByTestId } = renderComponent({
props: {
connection: instanceConnection({
configs: {
promote: promoteConfig({ hasCheckout: false, matchesConfig: false }),
apply: applyConfig({ hasCheckout: false, matchesConfig: false }),
},
}),
},
});
expect(getByTestId('promote-instance-button')).toBeDisabled();
expect(getByTestId('promote-instance-not-connected')).toBeInTheDocument();
expect(getByTestId('apply-instance-button')).toBeDisabled();
expect(getByTestId('apply-instance-not-connected')).toBeInTheDocument();
});
it('shows nothing when the connection is not loaded yet', () => {
const { queryByTestId } = renderComponent({ props: { connection: null } });
expect(queryByTestId('promote-instance-button')).toBeNull();
expect(queryByTestId('apply-instance-button')).toBeNull();
});
it('shows nothing when the connection has no promote or apply config', () => {
const { queryByTestId } = renderComponent({
props: { connection: instanceConnection({ configs: {} }) },
});
expect(queryByTestId('promote-instance-button')).toBeNull();
expect(queryByTestId('apply-instance-button')).toBeNull();
});
it('hides the promote action when the user lacks the push scope', () => {
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:pull'] });
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection() },
});
expect(queryByTestId('promote-instance-button')).toBeNull();
expect(getByTestId('apply-instance-button')).toBeInTheDocument();
});
it('hides the apply action when the user lacks the pull scope', () => {
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:push'] });
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection() },
});
expect(queryByTestId('apply-instance-button')).toBeNull();
expect(getByTestId('promote-instance-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,135 @@
<script setup lang="ts">
import {
N8nButton,
N8nIcon,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { useUsersStore } from '@n8n/stores/users.store';
import { computed, ref } from 'vue';
import ApplyInstanceDialog from './ApplyInstanceDialog.vue';
import PromoteInstanceDialog from './PromoteInstanceDialog.vue';
import { type PromotionConnection } from '../promotionsSettings.api';
const props = defineProps<{
connection: PromotionConnection | null;
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
const promoteDialogOpen = ref(false);
const applyDialogOpen = ref(false);
const promoteConfig = computed(() => props.connection?.configs.promote);
const applyConfig = computed(() => props.connection?.configs.apply);
const canPromote = computed(
() =>
!!promoteConfig.value &&
!!getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection.push,
);
const canApply = computed(
() =>
!!applyConfig.value &&
!!getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection.pull,
);
// Promote and apply each need a local checkout cloned from the current config. Until then
// the backend rejects the request, so the buttons wait for Connect in the form below.
const promoteConnected = computed(() => promoteConfig.value?.checkout.matchesConfig ?? false);
const applyConnected = computed(() => applyConfig.value?.checkout.matchesConfig ?? false);
</script>
<template>
<N8nSettingsSection
v-if="connection && (canPromote || canApply)"
:title="i18n.baseText('settings.promotions.instanceActions.title')"
:description="i18n.baseText('settings.promotions.instanceActions.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow v-if="canPromote && promoteConfig">
<template #visual>
<N8nIcon icon="arrow-up" color="text-dark" :size="20" />
</template>
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.promotions.promote.title') }}
</N8nText>
<N8nText v-if="promoteConnected" size="small" color="text-light">
{{ i18n.baseText('settings.promotions.promote.description') }}
</N8nText>
<N8nText
v-else
size="small"
color="text-light"
data-test-id="promote-instance-not-connected"
>
{{ i18n.baseText('settings.promotions.promote.notConnected') }}
</N8nText>
</template>
<template #action>
<N8nButton
:disabled="!promoteConnected"
data-test-id="promote-instance-button"
@click="promoteDialogOpen = true"
>
{{ i18n.baseText('settings.promotions.promote.button') }}
</N8nButton>
</template>
</N8nSettingsRow>
<N8nSettingsRow v-if="canApply && applyConfig">
<template #visual>
<N8nIcon icon="arrow-down" color="text-dark" :size="20" />
</template>
<template #info>
<N8nText bold size="medium" color="text-dark">
{{ i18n.baseText('settings.promotions.apply.title') }}
</N8nText>
<N8nText v-if="applyConnected" size="small" color="text-light">
{{ i18n.baseText('settings.promotions.apply.description') }}
</N8nText>
<N8nText
v-else
size="small"
color="text-light"
data-test-id="apply-instance-not-connected"
>
{{ i18n.baseText('settings.promotions.apply.notConnected') }}
</N8nText>
</template>
<template #action>
<N8nButton
:disabled="!applyConnected"
data-test-id="apply-instance-button"
@click="applyDialogOpen = true"
>
{{ i18n.baseText('settings.promotions.apply.button') }}
</N8nButton>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<PromoteInstanceDialog
v-if="promoteDialogOpen && promoteConfig"
:open="promoteDialogOpen"
:connection-id="connection.id"
:base-branch-name="promoteConfig.settings.baseBranchName"
:create-branch-on-promotion="promoteConfig.settings.createBranchOnPromotion"
@update:open="promoteDialogOpen = $event"
/>
<ApplyInstanceDialog
v-if="applyDialogOpen && applyConfig"
:open="applyDialogOpen"
:connection-id="connection.id"
:branch-name="applyConfig.settings.branchName"
@update:open="applyDialogOpen = $event"
/>
</N8nSettingsSection>
</template>
@@ -1,103 +0,0 @@
import { createTestingPinia } from '@pinia/testing';
import type { IUser } from '@n8n/rest-api-client';
import { useUsersStore } from '@n8n/stores/users.store';
import { mock } from 'vitest-mock-extended';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import type { PromotionConnection } from '../promotionsSettings.api';
import PromoteInstanceSection from './PromoteInstanceSection.vue';
const timestamps = {
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
};
const promoteConfig = (
baseBranchName = 'main',
createBranchOnPromotion = false,
checkout = { hasCheckout: true, matchesConfig: true },
) => ({
id: 'config-promote',
name: 'Promote',
settings: { schemaVersion: 1 as const, baseBranchName, createBranchOnPromotion },
checkout,
...timestamps,
});
const instanceConnection = (overrides: Partial<PromotionConnection> = {}): PromotionConnection =>
({
id: 'connection-1',
name: 'Production',
scope: 'instance',
target: { schemaVersion: 1, remoteUrl: '[email protected]:acme/workflows.git' },
provider: {
id: 'provider-ssh',
name: 'Production key',
type: 'git',
authType: 'ssh-key',
...timestamps,
},
configs: { promote: promoteConfig() },
...timestamps,
...overrides,
}) as PromotionConnection;
const renderComponent = createComponentRenderer(PromoteInstanceSection);
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
describe('PromoteInstanceSection', () => {
beforeEach(() => {
createTestingPinia();
usersStore = mockedStore(useUsersStore);
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:push'] });
});
it('shows the promote action when a promote config and push scope are present', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: { connection: instanceConnection() },
});
expect(getByTestId('promote-instance-button')).toBeEnabled();
expect(queryByTestId('promote-instance-not-connected')).toBeNull();
});
it('disables the promote action and explains why when the checkout is not connected', () => {
const { getByTestId } = renderComponent({
props: {
connection: instanceConnection({
configs: {
promote: promoteConfig('main', false, { hasCheckout: false, matchesConfig: false }),
},
}),
},
});
expect(getByTestId('promote-instance-button')).toBeDisabled();
expect(getByTestId('promote-instance-not-connected')).toBeInTheDocument();
});
it('shows nothing when the connection is not loaded yet', () => {
const { queryByTestId } = renderComponent({ props: { connection: null } });
expect(queryByTestId('promote-instance-button')).toBeNull();
});
it('shows nothing when the instance connection has no promote config', () => {
const { queryByTestId } = renderComponent({
props: { connection: instanceConnection({ configs: {} }) },
});
expect(queryByTestId('promote-instance-button')).toBeNull();
});
it('shows nothing when the user lacks the push scope', () => {
usersStore.currentUser = mock<IUser>({ globalScopes: [] });
const { queryByTestId } = renderComponent({
props: { connection: instanceConnection() },
});
expect(queryByTestId('promote-instance-button')).toBeNull();
});
});
@@ -1,64 +0,0 @@
<script setup lang="ts">
import { N8nButton, N8nSettingsSection, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { useUsersStore } from '@n8n/stores/users.store';
import { computed, ref } from 'vue';
import PromoteInstanceDialog from './PromoteInstanceDialog.vue';
import { type PromotionConnection } from '../promotionsSettings.api';
const props = defineProps<{
connection: PromotionConnection | null;
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
const dialogOpen = ref(false);
const promoteConfig = computed(() => props.connection?.configs.promote);
const canPromote = computed(
() =>
!!promoteConfig.value &&
!!getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection.push,
);
// A promote needs a local checkout cloned from the current config. Until then the
// backend rejects the push, so the button waits for Connect in the form below.
const isConnected = computed(() => promoteConfig.value?.checkout.matchesConfig ?? false);
</script>
<template>
<N8nSettingsSection
v-if="canPromote && connection && promoteConfig"
:title="i18n.baseText('settings.promotions.promote.title')"
:description="i18n.baseText('settings.promotions.promote.description')"
>
<N8nButton
:disabled="!isConnected"
data-test-id="promote-instance-button"
@click="dialogOpen = true"
>
{{ i18n.baseText('settings.promotions.promote.button') }}
</N8nButton>
<N8nText
v-if="!isConnected"
size="small"
color="text-light"
data-test-id="promote-instance-not-connected"
>
{{ i18n.baseText('settings.promotions.promote.notConnected') }}
</N8nText>
<PromoteInstanceDialog
v-if="dialogOpen"
:open="dialogOpen"
:connection-id="connection.id"
:base-branch-name="promoteConfig.settings.baseBranchName"
:create-branch-on-promotion="promoteConfig.settings.createBranchOnPromotion"
@update:open="dialogOpen = $event"
/>
</N8nSettingsSection>
</template>
@@ -153,11 +153,7 @@ describe('PromotionBanners', () => {
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' },
},
data: { projectId: 'project-1', direction: 'apply' },
});
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:list'] });
@@ -33,12 +33,7 @@ const gitConnectionPermissions = computed(
// 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 { hasPromoteConfig, hasApplyConfig, load: loadConnection } = useInstancePromotionConnection();
const canPreviewChanges = computed(
() =>
@@ -133,19 +128,10 @@ function onOpenPromotionModal() {
}
function onOpenIncomingModal() {
const applyConfig = connection.value?.configs.apply;
if (!currentProjectId.value || !connection.value || !applyConfig) return;
if (!currentProjectId.value) 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,
},
},
data: { projectId: currentProjectId.value, direction: 'apply' },
});
}
</script>
@@ -4,7 +4,7 @@ import { fireEvent, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { ApplyPackageResultDto } from '@n8n/api-types';
import PromotionBindingsDialog from './PromotionBindingsDialog.vue';
import { continueApplyPromotion } from '../promotionsSettings.api';
import { continueApplyProjectSelection, continueApplyPromotion } from '../promotionsSettings.api';
import {
applied,
blocked,
@@ -19,7 +19,9 @@ vi.mock('@n8n/stores/useRootStore', () => ({
}));
vi.mock('../promotionsSettings.api');
const renderComponent = createComponentRenderer(PromotionBindingsDialog);
const renderComponent = createComponentRenderer(PromotionBindingsDialog, {
props: { continueWith: { kind: 'instance' } },
});
async function renderDialog(options: Parameters<typeof renderComponent>[0]) {
const result = renderComponent(options);
@@ -92,6 +94,30 @@ it('creates the original binding, restores focus, and emits the full applied res
expect(emitted('close-requested')).toBeUndefined();
});
it('forwards continueWith so Continue resumes the selection', async () => {
vi.mocked(continueApplyProjectSelection).mockResolvedValue(applied);
const initial = blocked({ missingBindings: [] });
const { getByRole, emitted } = await renderDialog({
props: {
open: true,
blockedResult: initial,
createBinding: vi.fn(),
continueWith: { kind: 'selection', projectId: 'team-a', workflowIds: ['wf-a', 'wf-b'] },
},
});
await userEvent.click(getByRole('button', { name: 'Continue' }));
expect(continueApplyProjectSelection).toHaveBeenCalledWith(
{ baseUrl: '/custom/api/v1' },
'team-a',
{
workflowIds: ['wf-a', 'wf-b'],
expectedSource: { configId: initial.configId, ...initial.git },
},
);
expect(continueApplyPromotion).not.toHaveBeenCalled();
expect(emitted('applied')).toEqual([[applied]]);
});
it.each(['Close', 'Close dialog', 'Back', 'Escape'] as const)(
'keeps saved resources when dismissed with %s',
async (action) => {
@@ -15,6 +15,7 @@ import { usePromotionBindings } from '../composables/usePromotionBindings';
import type {
AppliedResult,
BlockedApplyResult,
ContinueTarget,
CreatedPromotionBinding,
CreatePromotionBinding,
MissingPromotionBinding,
@@ -25,6 +26,7 @@ const props = defineProps<{
open: boolean;
blockedResult: BlockedApplyResult;
createBinding: CreatePromotionBinding;
continueWith: ContinueTarget;
}>();
const emit = defineEmits<{
@@ -54,7 +56,7 @@ const {
watch(
() => props.open,
(open) => {
if (open) bindings.start(props.blockedResult);
if (open) bindings.start(props.blockedResult, props.continueWith);
else bindings.end();
},
{ immediate: true },
@@ -6,12 +6,17 @@ import { usePromotionBindingCreation } from '../composables/usePromotionBindingC
import type {
AppliedResult,
BlockedApplyResult,
ContinueTarget,
CreatedPromotionBinding,
CreatedPromotionProject,
SourceChangedResult,
} from '../promotions.types';
const props = defineProps<{ open: boolean; blockedResult: BlockedApplyResult }>();
const props = defineProps<{
open: boolean;
blockedResult: BlockedApplyResult;
continueWith: ContinueTarget;
}>();
const emit = defineEmits<{
'update:open': [open: boolean];
applied: [result: AppliedResult];
@@ -31,6 +36,7 @@ const { createBinding, createdProjects, updatePreflight } = usePromotionBindingC
:open="open"
:blocked-result="blockedResult"
:create-binding="createBinding"
:continue-with="continueWith"
@update:open="emit('update:open', $event)"
@preflight-updated="updatePreflight"
@applied="emit('applied', $event)"
@@ -64,8 +64,11 @@ const mockChanges = [
},
];
/** The endpoint wraps the rows with the commit they were read from. */
const changesBody = (changes: unknown[]) => ({ commitSha: 'a'.repeat(40), changes });
/** The endpoint wraps the rows with the source and commit they were read from. */
const changesBody = (
changes: unknown[],
source = { configId: 'config-1', branchName: 'main' },
) => ({ commitSha: 'a'.repeat(40), source, changes });
const promoteResult = (branchName = 'main'): PromotePackageResultDto =>
({
@@ -82,21 +85,19 @@ const promoteResult = (branchName = 'main'): PromotePackageResultDto =>
git: { commitSha: 'abc123', branchName },
}) satisfies PromotePackageResultDto;
const renderComponent = createComponentRenderer(PromotionSelectModal, {
global: {
stubs: {
N8nDialog: { props: ['open'], template: '<div v-if="open"><slot /></div>' },
Modal: {
template: `
<div>
<slot name="content" />
<slot name="footer" />
</div>
`,
},
},
const stubs = {
N8nDialog: { props: ['open'], template: '<div v-if="open"><slot /></div>' },
Modal: {
template: `
<div>
<slot name="content" />
<slot name="footer" />
</div>
`,
},
});
};
const renderComponent = createComponentRenderer(PromotionSelectModal, { global: { stubs } });
describe('PromotionSelectModal', () => {
let pinia: ReturnType<typeof createTestingPinia>;
@@ -424,7 +425,6 @@ describe('PromotionSelectModal', () => {
data: {
projectId: 'project-1',
direction: 'apply' as const,
apply: { connectionId: 'connection-1', configId: 'config-1', branchName: 'main' },
},
};
const applyChanges = vi.fn(() => ({ data: changesBody(mockChanges) }));
@@ -441,43 +441,96 @@ describe('PromotionSelectModal', () => {
let applied: ReturnType<typeof vi.spyOn>;
// The DTO needs at least one workflow, so every apply test starts from a full selection.
async function selectAll(findByTestId: (id: string) => Promise<HTMLElement>) {
await userEvent.click(await findByTestId('promotion-select-all'));
}
beforeEach(() => {
server.get('/rest/promotions/project-1/changes/apply', applyChanges);
server.post('/api/v1/promotions/connections/connection-1/apply', applyPackage);
server.post('/api/v1/promotions/projects/project-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 });
it('should offer to apply the selection instead of promoting', async () => {
const { findByTestId, findByText, queryByTestId } = renderComponent({
pinia,
props: applyProps,
});
await findByText('Payment Handler');
expect(await findByTestId('promotion-apply-all')).toHaveTextContent('Apply all changes');
const applyButton = await findByTestId('promotion-apply-selected');
// Nothing is selected yet, so the additive apply stays disabled.
expect(applyButton).toBeDisabled();
await selectAll(findByTestId);
expect(applyButton).toBeEnabled();
expect(applyButton).toHaveTextContent('Apply 2 changes');
expect(queryByTestId('promotion-submit')).not.toBeInTheDocument();
expect(applyChanges).toHaveBeenCalledTimes(1);
});
it('should use the singular apply label when one change is selected', async () => {
const { findAllByTestId, findByTestId } = renderComponent({ pinia, props: applyProps });
const rows = await findAllByTestId('promotion-change-row');
await userEvent.click(rows[0]);
expect(await findByTestId('promotion-apply-selected')).toHaveTextContent('Apply 1 change');
});
it('should pin the source the preview resolved for the project', async () => {
// A project with its own connection previews against that connection's config.
server.get('/rest/promotions/project-1/changes/apply', () => ({
data: changesBody(mockChanges, { configId: 'config-project', branchName: 'production' }),
}));
mockedStore(useProjectsStore).fetchProject.mockResolvedValue(
createTestProject({ id: 'project-1' }),
);
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() => expect(applyPackage).toHaveBeenCalledTimes(1));
expect(applyPackage.mock.results[0].value.receivedBody.expectedSource).toEqual({
configId: 'config-project',
branchName: 'production',
commitSha: 'a'.repeat(40),
});
});
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 selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
expect(confirm).toHaveBeenCalledTimes(1);
// The confirmation names the additive copy, not the destructive whole-branch copy.
expect(confirm).toHaveBeenCalledWith(
'This applies only the selected workflows to this project. It does not change or remove anything else.',
'Apply the selected workflows?',
expect.objectContaining({ confirmButtonText: 'Apply' }),
);
expect(applyPackage).not.toHaveBeenCalled();
});
it('should apply the branch, report the counts and close the modal', async () => {
it('should apply the selection, 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 selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() => expect(applyPackage).toHaveBeenCalledTimes(1));
// The reviewed commit travels with the request, so a moved branch is not applied blindly.
// Only the selected workflows travel, pinned to the reviewed commit.
expect(applyPackage.mock.results[0].value.receivedBody).toEqual({
workflowIds: ['wf-001', 'wf-002'],
expectedSource: { configId: 'config-1', branchName: 'main', commitSha: 'a'.repeat(40) },
});
await waitFor(() =>
@@ -497,7 +550,7 @@ describe('PromotionSelectModal', () => {
});
it('should warn when applied workflows could not be published', async () => {
server.post('/api/v1/promotions/connections/connection-1/apply', () => ({
server.post('/api/v1/promotions/projects/project-1/apply', () => ({
connectionId: 'connection-1',
configId: 'config-1',
status: 'applied',
@@ -515,8 +568,9 @@ describe('PromotionSelectModal', () => {
}));
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() =>
expect(showMessage).toHaveBeenCalledWith(
@@ -543,8 +597,9 @@ describe('PromotionSelectModal', () => {
);
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() =>
expect(applied).toHaveBeenCalledWith('projectRemoved', { projectId: 'project-1' }),
@@ -565,8 +620,9 @@ describe('PromotionSelectModal', () => {
mockedStore(useProjectsStore).fetchProject.mockRejectedValue(new Error('offline'));
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() =>
expect(applied).toHaveBeenCalledWith('applied', { projectId: 'project-1' }),
@@ -575,13 +631,14 @@ describe('PromotionSelectModal', () => {
it('opens all blocked bindings and keeps Apply paused', async () => {
const result = blocked({ missingBindings: [credential, variable] });
server.post('/api/v1/promotions/connections/connection-1/apply', () => result);
server.post('/api/v1/promotions/projects/project-1/apply', () => result);
const { findByTestId, findAllByText, findByText } = renderComponent({
pinia,
props: applyProps,
});
await findByText('Payment Handler');
await userEvent.click(await findByTestId('promotion-apply-all'));
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-selected'));
await findByText('Resolve bindings');
await findAllByText(credential.name);
await findAllByText(variable.name);
@@ -591,6 +648,43 @@ describe('PromotionSelectModal', () => {
expect(showMessage).not.toHaveBeenCalled();
});
it('should lock the selection during apply and continue with the submitted one', async () => {
let resolveApply: (value: unknown) => void = () => {};
server.post(
'/api/v1/promotions/projects/project-1/apply',
async () => await new Promise((resolve) => (resolveApply = resolve)),
);
const { findAllByTestId, findByTestId } = renderComponent({
pinia,
props: applyProps,
global: {
stubs: {
...stubs,
PromotionBindingsFlow: {
props: ['continueWith'],
template:
'<div data-test-id="continue-with">{{ continueWith.kind }}:{{ continueWith.workflowIds.join(",") }}</div>',
},
},
},
});
const rows = await findAllByTestId('promotion-change-row');
await userEvent.click(rows[0]);
const applyButton = await findByTestId('promotion-apply-selected');
await userEvent.click(applyButton);
await waitFor(() => expect(applyButton).toHaveAttribute('aria-busy', 'true'));
await userEvent.click(rows[1]);
await userEvent.click(await findByTestId('promotion-select-all'));
// Clicks during the request do not change what is applied.
expect(applyButton).toHaveTextContent('Apply 1 change');
resolveApply(blocked({ missingBindings: [credential] }));
expect(await findByTestId('continue-with')).toHaveTextContent('selection:wf-001');
expect(await findByTestId('continue-with')).not.toHaveTextContent('wf-002');
});
it.each([
{
result: { status: 'source-changed' },
@@ -599,7 +693,7 @@ describe('PromotionSelectModal', () => {
])(
'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', () => ({
server.post('/api/v1/promotions/projects/project-1/apply', () => ({
connectionId: 'connection-1',
configId: 'config-1',
git: { commitSha: 'a'.repeat(40), branchName: 'main' },
@@ -607,8 +701,9 @@ describe('PromotionSelectModal', () => {
}));
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() =>
expect(showMessage).toHaveBeenCalledWith(
@@ -624,13 +719,14 @@ describe('PromotionSelectModal', () => {
it('should show the error and still refetch the changes when apply fails', async () => {
server.post(
'/api/v1/promotions/connections/connection-1/apply',
'/api/v1/promotions/projects/project-1/apply',
() => new Response(409, {}, { message: 'Bindings unresolved' }),
);
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
await findByText('Payment Handler');
await selectAll(findByTestId);
await userEvent.click(await findByTestId('promotion-apply-all'));
await userEvent.click(await findByTestId('promotion-apply-selected'));
await waitFor(() =>
expect(showError).toHaveBeenCalledWith(expect.anything(), 'Could not apply the changes'),
@@ -20,7 +20,7 @@ import {
} from '@n8n/api-types';
import { usePromotionChanges } from '../composables/usePromotionChanges';
import { promotionEventBus } from '../promotions.eventBus';
import { applyPromotion } from '../promotionsSettings.api';
import { applyProjectSelection } from '../promotionsSettings.api';
import { getPromoteErrorMessage } from '../promoteErrorMessage';
import PromotionBindingsFlow from './PromotionBindingsFlow.vue';
import type { AppliedResult, BlockedApplyResult } from '../promotions.types';
@@ -30,8 +30,6 @@ interface Props {
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 };
};
}
@@ -50,10 +48,13 @@ const { direction } = props.data;
const isIncoming = direction === 'apply';
const isApplying = ref(false);
const blockedResult = shallowRef<BlockedApplyResult>();
// Continue must apply the selection that produced the preflight, not the live one.
const blockedWorkflowIds = shallowRef<string[]>([]);
const {
changes,
commitSha,
source,
filteredChanges,
isLoading,
isSubmitting,
@@ -70,6 +71,19 @@ const {
toggleSelectAll,
} = usePromotionChanges(props.data.projectId, direction);
// The selection is locked while a promote or an apply request runs.
const isSelectionLocked = computed(() => isSubmitting.value || isApplying.value);
function onToggleSelected(id: string) {
if (isSelectionLocked.value) return;
toggleSelected(id);
}
function onToggleSelectAll() {
if (isSelectionLocked.value) return;
toggleSelectAll();
}
const title = i18n.baseText(
isIncoming ? 'promotions.modal.incoming.title' : 'promotions.modal.title',
);
@@ -121,6 +135,15 @@ function getPromoteButtonLabel(): string {
});
}
function getApplySelectedButtonLabel(): string {
if (selectedCount.value === 1) {
return i18n.baseText('promotions.modal.incoming.applySelectedSingle');
}
return i18n.baseText('promotions.modal.incoming.applySelected', {
interpolate: { count: String(selectedCount.value) },
});
}
function isSelected(id: string): boolean {
return selectedIds.value.has(id);
}
@@ -209,22 +232,22 @@ async function announceApplied() {
async function onApplied(result: AppliedResult) {
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),
},
});
const interpolate = {
created: String(workflows.created),
updated: String(workflows.updated),
archived: String(workflows.archived),
deleted: String(workflows.deleted),
notPublished: String(notPublished),
};
// 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,
message: i18n.baseText(
notPublished
? 'promotions.modal.incoming.applied.messageNotPublished'
: 'promotions.modal.incoming.applied.message',
{ interpolate },
),
type: notPublished ? 'warning' : 'success',
});
// Close before the project lookup, so the stale change list does not show again.
@@ -242,36 +265,37 @@ async function onSourceChanged() {
});
}
/** Applies the whole branch. The selection is kept for the selective apply that follows. */
async function onApplyAll() {
const { apply } = props.data;
if (!apply) return;
/** Applies only the selected workflows. Unselected content on the instance is left as is. */
async function onApplySelected() {
const confirmed = await message.confirm(
i18n.baseText('promotions.modal.incoming.confirm.message'),
i18n.baseText('promotions.modal.incoming.confirm.title'),
i18n.baseText('promotions.modal.incoming.confirmSelected.message'),
i18n.baseText('promotions.modal.incoming.confirmSelected.title'),
{
type: 'warning',
confirmButtonText: i18n.baseText('promotions.modal.incoming.confirm.confirmButtonText'),
confirmButtonText: i18n.baseText(
'promotions.modal.incoming.confirmSelected.confirmButtonText',
),
cancelButtonText: i18n.baseText('promotions.modal.close'),
},
);
if (confirmed !== MODAL_CONFIRM) return;
isApplying.value = true;
const workflowIds = Array.from(selectedIds.value);
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 },
);
// Pin the reviewed commit on the source the preview resolved: a branch that moved since
// the preview is reported, not applied.
const expectedSource =
commitSha.value && source.value ? { ...source.value, commitSha: commitSha.value } : undefined;
const result = await applyProjectSelection(rootStore.publicApiContext, props.data.projectId, {
workflowIds,
expectedSource,
});
if (result.status === 'applied') {
await onApplied(result);
return;
}
if (result.status === 'blocked') {
blockedWorkflowIds.value = workflowIds;
blockedResult.value = result;
return;
}
@@ -316,9 +340,9 @@ onMounted(async () => {
<N8nCheckbox
:model-value="allSelected"
:indeterminate="someSelected"
:disabled="isSubmitting"
:disabled="isSelectionLocked"
data-test-id="promotion-select-all"
@update:model-value="toggleSelectAll"
@update:model-value="onToggleSelectAll"
/>
<N8nInput
v-model="searchQuery"
@@ -403,17 +427,17 @@ onMounted(async () => {
:class="[
$style.row,
isSelected(change.id) && $style.rowSelected,
isSubmitting && $style.rowDisabled,
isSelectionLocked && $style.rowDisabled,
index === 0 && $style.rowFirst,
index === filteredChanges.length - 1 && $style.rowLast,
]"
data-test-id="promotion-change-row"
@click="toggleSelected(change.id)"
@click="onToggleSelected(change.id)"
>
<N8nCheckbox
:model-value="isSelected(change.id)"
:disabled="isSubmitting"
@update:model-value="toggleSelected(change.id)"
:disabled="isSelectionLocked"
@update:model-value="onToggleSelected(change.id)"
@click.stop
/>
@@ -481,11 +505,11 @@ onMounted(async () => {
<N8nButton
v-if="isIncoming"
:loading="isApplying"
:disabled="isLoading || !!error"
data-test-id="promotion-apply-all"
@click="onApplyAll"
:disabled="isLoading || !!error || selectedCount === 0"
data-test-id="promotion-apply-selected"
@click="onApplySelected"
>
{{ i18n.baseText('promotions.modal.incoming.applyAll') }}
{{ getApplySelectedButtonLabel() }}
</N8nButton>
<N8nButton
v-else
@@ -504,6 +528,11 @@ onMounted(async () => {
v-else
:open="true"
:blocked-result="blockedResult"
:continue-with="{
kind: 'selection',
projectId: props.data.projectId,
workflowIds: blockedWorkflowIds,
}"
@update:open="
(open) => {
if (!open) blockedResult = undefined;
@@ -2,7 +2,7 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { ResponseError } from '@n8n/rest-api-client';
import type { ApplyPackageResultDto } from '@n8n/api-types';
import { promotionBindingKey, usePromotionBindings } from './usePromotionBindings';
import { continueApplyPromotion } from '../promotionsSettings.api';
import { continueApplyProjectSelection, continueApplyPromotion } from '../promotionsSettings.api';
import {
applied,
blocked,
@@ -11,13 +11,24 @@ import {
savedCredential,
variable,
} from '../__tests__/bindings.fixtures';
import type { CreatedPromotionBinding, MissingPromotionBinding } from '../promotions.types';
import type {
ContinueTarget,
CreatedPromotionBinding,
MissingPromotionBinding,
} from '../promotions.types';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ publicApiContext: { baseUrl: '/custom/api/v1' } }),
}));
vi.mock('../promotionsSettings.api');
const instance: ContinueTarget = { kind: 'instance' };
const selection: Extract<ContinueTarget, { kind: 'selection' }> = {
kind: 'selection',
projectId: 'team-a',
workflowIds: ['wf-a', 'wf-b'],
};
beforeEach(() => vi.resetAllMocks());
it('resolves shared credentials once and keeps variable scopes separate', async () => {
@@ -33,7 +44,7 @@ it('resolves shared credentials once and keeps variable scopes separate', async
const result = blocked({
missingBindings: [credential, variable, projectVariable, otherProjectVariable],
});
session.start(result);
session.start(result, instance);
expect(session.originalResult.value).toBe(result);
expect(session.unresolvedCount.value).toBe(4);
expect(session.groups.value.map((group) => group.project.id)).toEqual(['team-a', 'team-b']);
@@ -66,7 +77,7 @@ it.each([
['wrong type', { ...savedCredential, credentialType: 'other' }],
] as const)('keeps a credential unresolved when creation returns %s', async (_label, result) => {
const session = usePromotionBindings();
session.start(blocked());
session.start(blocked(), instance);
await session.createBinding(promotionBindingKey(credential), async () => result);
expect(session.unresolvedCount.value).toBe(1);
expect(session.savedResources.value).toEqual([]);
@@ -75,7 +86,7 @@ it.each([
it('keeps global variables unresolved after a project variable is saved', async () => {
const session = usePromotionBindings();
session.start(blocked({ missingBindings: [variable] }));
session.start(blocked({ missingBindings: [variable] }), instance);
await session.createBinding(promotionBindingKey(variable), async (item) => {
expect(item).toBe(variable);
return {
@@ -92,7 +103,7 @@ it('keeps global variables unresolved after a project variable is saved', async
it('guards creation and Continue while the editor is open and recovers after failure', async () => {
const pending = createDeferredPromise<CreatedPromotionBinding | null>();
const session = usePromotionBindings();
session.start(blocked());
session.start(blocked(), instance);
const create = vi.fn().mockReturnValue(pending.promise);
const first = session.createBinding(promotionBindingKey(credential), create);
await session.createBinding(promotionBindingKey(credential), create);
@@ -126,6 +137,7 @@ it.each(['access', 'conflict'] as const)('blocks Continue for %s requirements',
]
: [],
}),
instance,
);
expect(session.canContinue.value).toBe(false);
expect(session.unresolvedCount.value).toBe(1);
@@ -148,6 +160,7 @@ it('counts credentials with the same name and different types separately', () =>
referenceFiles: ['workflow.json'],
})),
}),
instance,
);
expect(session.unresolvedCount.value).toBe(2);
expect(session.canContinue.value).toBe(false);
@@ -158,7 +171,7 @@ it('submits once with the original source and allows warnings', async () => {
vi.mocked(continueApplyPromotion).mockReturnValue(pending.promise);
const session = usePromotionBindings();
const result = blocked({ missingBindings: [], warnings: applied.warnings });
session.start(result);
session.start(result, instance);
expect(session.canContinue.value).toBe(true);
const first = session.continueApply();
await session.continueApply();
@@ -168,6 +181,7 @@ it('submits once with the original source and allows warnings', async () => {
result.connectionId,
{ expectedSource: { configId: result.configId, ...result.git } },
);
expect(continueApplyProjectSelection).not.toHaveBeenCalled();
pending.resolve(applied);
expect(await first).toBe(applied);
expect(session.canContinue.value).toBe(false);
@@ -175,10 +189,52 @@ it('submits once with the original source and allows warnings', async () => {
expect(continueApplyPromotion).toHaveBeenCalledTimes(1);
});
it('continues a selection through the project endpoint', async () => {
vi.mocked(continueApplyProjectSelection).mockResolvedValue(applied);
const session = usePromotionBindings();
const result = blocked({ missingBindings: [] });
session.start(result, selection);
expect(session.canContinue.value).toBe(true);
expect(await session.continueApply()).toBe(applied);
expect(continueApplyProjectSelection).toHaveBeenCalledTimes(1);
expect(continueApplyProjectSelection).toHaveBeenCalledWith(
{ baseUrl: '/custom/api/v1' },
'team-a',
{
workflowIds: ['wf-a', 'wf-b'],
expectedSource: { configId: result.configId, ...result.git },
},
);
expect(continueApplyPromotion).not.toHaveBeenCalled();
expect(session.canContinue.value).toBe(false);
});
it('reconciles a blocked selection result and stops on a source change', async () => {
const session = usePromotionBindings();
const initial = blocked({ missingBindings: [] });
session.start(initial, { ...selection, workflowIds: ['wf-a'] });
const next = blocked({ missingBindings: [credential] });
vi.mocked(continueApplyProjectSelection).mockResolvedValueOnce(next);
await session.continueApply();
expect(session.preflight.value).toBe(next.preflight);
expect(session.unresolvedCount.value).toBe(1);
await session.createBinding(promotionBindingKey(credential), async () => savedCredential);
const changed = {
...initial,
status: 'source-changed' as const,
git: { branchName: 'main', commitSha: 'b'.repeat(40) },
};
vi.mocked(continueApplyProjectSelection).mockResolvedValueOnce(changed);
expect(await session.continueApply()).toBe(changed);
expect(session.sourceChanged.value).toBe(true);
expect(continueApplyProjectSelection).toHaveBeenCalledTimes(2);
expect(continueApplyPromotion).not.toHaveBeenCalled();
});
it('replaces blockers and preserves saved rows after another blocked result', async () => {
const session = usePromotionBindings();
const result = blocked({ missingBindings: [credential, variable] });
session.start(result);
session.start(result, instance);
await session.createBinding(promotionBindingKey(credential), async () => savedCredential);
await session.createBinding(promotionBindingKey(variable), async () => ({
kind: 'variable',
@@ -205,7 +261,7 @@ it('replaces blockers and preserves saved rows after another blocked result', as
it('stops after a source change without retrying', async () => {
const session = usePromotionBindings();
const initial = blocked({ missingBindings: [] });
session.start(initial);
session.start(initial, instance);
const changed = {
...initial,
status: 'source-changed' as const,
@@ -223,7 +279,7 @@ it.each([new Error('Offline'), new ResponseError('Forbidden', { httpStatusCode:
'keeps saved items after a request fails: %s',
async (error) => {
const session = usePromotionBindings();
session.start(blocked());
session.start(blocked(), instance);
await session.createBinding(promotionBindingKey(credential), async () => savedCredential);
vi.mocked(continueApplyPromotion).mockRejectedValue(error);
await session.continueApply();
@@ -237,13 +293,13 @@ it.each([new Error('Offline'), new ResponseError('Forbidden', { httpStatusCode:
it('ignores an editor result from a closed session', async () => {
const pending = createDeferredPromise<CreatedPromotionBinding | null>();
const session = usePromotionBindings();
session.start(blocked());
session.start(blocked(), instance);
const creating = session.createBinding(
promotionBindingKey(credential),
async () => await pending.promise,
);
session.end();
session.start(blocked());
session.start(blocked(), instance);
pending.resolve(savedCredential);
await creating;
expect(session.savedResources.value).toEqual([]);
@@ -1,9 +1,10 @@
import { computed, ref, shallowRef } from 'vue';
import type { PromotionBindingConsumer, ContinueApplyPackageDto } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { continueApplyPromotion } from '../promotionsSettings.api';
import { continueApplyProjectSelection, continueApplyPromotion } from '../promotionsSettings.api';
import type {
BlockedApplyResult,
ContinueTarget,
CreatedPromotionBinding,
CreatePromotionBinding,
MissingPromotionBinding,
@@ -71,6 +72,7 @@ export function usePromotionBindings() {
let session = 0;
let expectedSource: ContinueApplyPackageDto['expectedSource'] | undefined;
let connectionId: string | undefined;
let continueTarget: ContinueTarget | undefined;
function statusOf(key: string, projectId: string): BindingStatus {
if (missingKeys.value.has(key)) return 'missing';
@@ -139,10 +141,11 @@ export function usePromotionBindings() {
);
}
function start(result: BlockedApplyResult) {
function start(result: BlockedApplyResult, target: ContinueTarget) {
session++;
originalResult.value = result;
connectionId = result.connectionId;
continueTarget = target;
expectedSource = { configId: result.configId, ...result.git };
knownBindings.value = new Map();
createdBindings.value = new Map();
@@ -192,14 +195,24 @@ export function usePromotionBindings() {
}
async function continueApply() {
if (!canContinue.value || !connectionId || !expectedSource) return;
if (!canContinue.value || !connectionId || !expectedSource || !continueTarget) return;
const currentSession = session;
isSubmitting.value = true;
error.value = null;
try {
const result = await continueApplyPromotion(rootStore.publicApiContext, connectionId, {
expectedSource: { ...expectedSource },
});
const result =
continueTarget.kind === 'selection'
? await continueApplyProjectSelection(
rootStore.publicApiContext,
continueTarget.projectId,
{
workflowIds: continueTarget.workflowIds,
expectedSource: { ...expectedSource },
},
)
: await continueApplyPromotion(rootStore.publicApiContext, connectionId, {
expectedSource: { ...expectedSource },
});
if (currentSession !== session) return;
if (result.status === 'blocked') reconcile(result);
if (result.status === 'source-changed') sourceChanged.value = true;
@@ -18,6 +18,7 @@ type Pending = {
const changes = (count: number): PromotionChanges => ({
commitSha: 'a'.repeat(40),
source: { configId: 'config-1', branchName: 'main' },
changes: Array.from({ length: count }, (_, index) => ({
id: `workflow-${index}`,
name: `Workflow ${index}`,
@@ -55,12 +55,14 @@ const mockChanges = [
];
const COMMIT_SHA = 'a'.repeat(40);
const SOURCE = { configId: 'config-1', branchName: 'main' };
describe('usePromotionChanges', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValue({
commitSha: COMMIT_SHA,
source: SOURCE,
changes: mockChanges,
});
});
@@ -75,6 +77,7 @@ describe('usePromotionChanges', () => {
vi.mocked(promotionsApi.getPromotableChanges).mockResolvedValueOnce({
commitSha: COMMIT_SHA,
source: SOURCE,
changes: mockChanges.filter((change) => change.id !== 'wf-001'),
});
await fetchChanges();
@@ -83,9 +86,10 @@ describe('usePromotionChanges', () => {
expect(selectedCount.value).toBe(1);
});
it('should request the given direction and keep the commit the rows came from', async () => {
const { fetchChanges, commitSha } = usePromotionChanges('project-1', 'apply');
it('should request the given direction and keep the source the rows came from', async () => {
const { fetchChanges, commitSha, source } = usePromotionChanges('project-1', 'apply');
expect(commitSha.value).toBeNull();
expect(source.value).toBeNull();
await fetchChanges();
@@ -95,6 +99,7 @@ describe('usePromotionChanges', () => {
'apply',
);
expect(commitSha.value).toBe(COMMIT_SHA);
expect(source.value).toEqual(SOURCE);
});
it('should handle fetch errors', async () => {
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue';
import type {
PromotableResource,
PromotePackageResultDto,
PromotionChanges,
PromotionDirection,
} from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
@@ -12,6 +13,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
const changes = ref<PromotableResource[]>([]);
const commitSha = ref<string | null>(null);
const source = ref<PromotionChanges['source'] | null>(null);
const isLoading = ref(false);
const isSubmitting = ref(false);
const error = ref<Error | null>(null);
@@ -54,6 +56,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
const result = await getPromotableChanges(rootStore.restApiContext, projectId, direction);
changes.value = result.changes;
commitSha.value = result.commitSha;
source.value = result.source;
reconcileSelection();
lastRefreshedAt.value = new Date().toISOString();
} catch (e) {
@@ -102,6 +105,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
return {
changes,
commitSha,
source,
filteredChanges,
isLoading,
isSubmitting,
@@ -23,6 +23,12 @@ export type CreatedPromotionBinding =
export type CreatedPromotionProject = { id: string; name: string };
// The endpoint that Continue resumes. 'instance' applies every team project and deletes the
// projects that are not in the package, so each caller must choose it explicitly.
export type ContinueTarget =
| { kind: 'instance' }
| { kind: 'selection'; projectId: string; workflowIds: string[] };
// Resolve after the editor closes. Return null on cancellation. Save errors keep the editor open.
// Report a setup error, then reject.
export type CreatePromotionBinding = (
@@ -1,5 +1,9 @@
import { request } from '@n8n/rest-api-client';
import { continueApplyPromotion } from './promotionsSettings.api';
import {
applyProjectSelection,
continueApplyProjectSelection,
continueApplyPromotion,
} from './promotionsSettings.api';
import { blocked } from './__tests__/bindings.fixtures';
vi.mock('@n8n/rest-api-client', () => ({ request: vi.fn() }));
@@ -18,3 +22,39 @@ it('uses the public API context and returns the complete result', async () => {
data: body,
});
});
it('posts the selection to the project apply endpoint', async () => {
const result = blocked();
const context = { baseUrl: '/custom/api/v2' };
vi.mocked(request).mockResolvedValue(result);
const body = {
workflowIds: ['wf-a', 'wf-b'],
expectedSource: { configId: result.configId, ...result.git },
};
expect(await applyProjectSelection(context, 'project-id', body)).toBe(result);
expect(request).toHaveBeenLastCalledWith({
method: 'POST',
baseURL: context.baseUrl,
endpoint: '/promotions/projects/project-id/apply',
data: body,
});
});
it('posts the selection to the project apply continue endpoint', async () => {
const result = blocked();
const context = { baseUrl: '/custom/api/v2' };
vi.mocked(request).mockResolvedValue(result);
const body = {
workflowIds: ['wf-a', 'wf-b'],
expectedSource: { configId: result.configId, ...result.git },
};
expect(await continueApplyProjectSelection(context, 'project-id', body)).toBe(result);
expect(request).toHaveBeenLastCalledWith({
method: 'POST',
baseURL: context.baseUrl,
endpoint: '/promotions/projects/project-id/apply/continue',
data: body,
});
});
@@ -1,7 +1,9 @@
import type {
ApplyPackageDto,
ApplyPackageResultDto,
ApplySelectionDto,
ContinueApplyPackageDto,
ContinueApplySelectionDto,
CreatePromotionConnectionDto,
CreatePromotionProviderDto,
PromotionApplyConfigPublicDto,
@@ -251,3 +253,29 @@ export const continueApplyPromotion = async (
endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply/continue`,
data: payload,
});
/** Applies only the chosen workflows into the destination project. */
export const applyProjectSelection = async (
context: PublicApiContext,
projectId: string,
payload: ApplySelectionDto,
): Promise<ApplyPackageResultDto> =>
await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/projects/${projectId}/apply`,
data: payload,
});
/** Resumes a selective apply that paused on unresolved bindings, once they are created. */
export const continueApplyProjectSelection = async (
context: PublicApiContext,
projectId: string,
payload: ContinueApplySelectionDto,
): Promise<ApplyPackageResultDto> =>
await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/projects/${projectId}/apply/continue`,
data: payload,
});
@@ -16,7 +16,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import PromoteInstanceSection from '../components/PromoteInstanceSection.vue';
import PromoteApplyInstanceSection from '../components/PromoteApplyInstanceSection.vue';
import PromotionConnectionForm from '../components/PromotionConnectionForm.vue';
import PromotionProviderDialog from '../components/PromotionProviderDialog.vue';
import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
@@ -148,7 +148,7 @@ async function onDialogOpenChange(open: boolean) {
:show-docs-link="false"
/>
<PromoteInstanceSection :connection="connection" />
<PromoteApplyInstanceSection :connection="connection" />
<N8nSettingsSection
:title="i18n.baseText('settings.promotions.providers.title')"