mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Claude Opus 4.8
parent
370b30f919
commit
aef216cb25
+11
-3
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+56
-2
@@ -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>>;
|
||||
|
||||
+13
@@ -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.",
|
||||
|
||||
+267
@@ -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]]);
|
||||
});
|
||||
});
|
||||
+215
@@ -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>
|
||||
+145
@@ -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();
|
||||
});
|
||||
});
|
||||
+135
@@ -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>
|
||||
-103
@@ -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();
|
||||
});
|
||||
});
|
||||
-64
@@ -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>
|
||||
+1
-5
@@ -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'] });
|
||||
|
||||
+3
-17
@@ -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>
|
||||
|
||||
+28
-2
@@ -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) => {
|
||||
|
||||
+3
-1
@@ -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 },
|
||||
|
||||
+7
-1
@@ -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)"
|
||||
|
||||
+132
-36
@@ -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'),
|
||||
|
||||
+71
-42
@@ -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;
|
||||
|
||||
+68
-12
@@ -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([]);
|
||||
|
||||
+19
-6
@@ -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;
|
||||
|
||||
+1
@@ -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}`,
|
||||
|
||||
+7
-2
@@ -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 () => {
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+6
@@ -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 = (
|
||||
|
||||
+41
-1
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
+28
@@ -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,
|
||||
});
|
||||
|
||||
+2
-2
@@ -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')"
|
||||
|
||||
Reference in New Issue
Block a user