diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotable-resource.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotable-resource.dto.test.ts index 45659092b72..cac22e9ab5f 100644 --- a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotable-resource.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotable-resource.dto.test.ts @@ -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); }); }); diff --git a/packages/@n8n/api-types/src/dto/promotions/promotable-resource.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotable-resource.dto.ts index a4e72ee9e7b..c292b729e13 100644 --- a/packages/@n8n/api-types/src/dto/promotions/promotable-resource.dto.ts +++ b/packages/@n8n/api-types/src/dto/promotions/promotable-resource.dto.ts @@ -39,6 +39,7 @@ export type PromotableResource = z.infer; export const promotionChangesSchema = z.object({ commitSha: z.string().nullable(), + source: z.object({ configId: n8nIdSchema, branchName: z.string() }), changes: promotableResourceSchema.array(), }); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-changes.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-changes.integration.test.ts index efac88ca51f..b3cdfeb8038 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-changes.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-changes.integration.test.ts @@ -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 { 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); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts index db673bcd027..2681d5e15df 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts @@ -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', ); diff --git a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts index 9f3e2914261..67b2afa4e2d 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts @@ -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. */ diff --git a/packages/cli/src/modules/promotions.ee/promotions.service.ts b/packages/cli/src/modules/promotions.ee/promotions.service.ts index 4c9ce168555..5d9a15984ba 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.service.ts @@ -735,6 +735,8 @@ export class PromotionsService { }); return { + configId: input.configId, + branchName, commitSha, files: parseBaseBranchFiles(lsTreeOutput, { exportRoot: PACKAGE_SUBFOLDER, projectId }), readFiles: async (filePaths) => { diff --git a/packages/cli/src/modules/promotions.ee/promotions.types.ts b/packages/cli/src/modules/promotions.ee/promotions.types.ts index f2eb56a1437..b6de49a092f 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.types.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.types.ts @@ -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>; diff --git a/packages/cli/src/public-api/v1/handlers/promotions/spec/paths/getPromotionChanges.generated.yml b/packages/cli/src/public-api/v1/handlers/promotions/spec/paths/getPromotionChanges.generated.yml index cc7e08f69d4..065da3f13f9 100644 --- a/packages/cli/src/public-api/v1/handlers/promotions/spec/paths/getPromotionChanges.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/promotions/spec/paths/getPromotionChanges.generated.yml @@ -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 diff --git a/packages/cli/test/integration/public-api/promotions.test.ts b/packages/cli/test/integration/public-api/promotions.test.ts index 4645c1e8994..17265bf8e92 100644 --- a/packages/cli/test/integration/public-api/promotions.test.ts +++ b/packages/cli/test/integration/public-api/promotions.test.ts @@ -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', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index f793e6865ae..e93f2f09432 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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.", diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.test.ts new file mode 100644 index 00000000000..df865d97d9f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.test.ts @@ -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(), + continueApplyPromotion: vi.fn(), + continueApplyProjectSelection: vi.fn(), +})); + +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]]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.vue new file mode 100644 index 00000000000..561b63e9688 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/ApplyInstanceDialog.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.test.ts new file mode 100644 index 00000000000..604811b9eab --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.test.ts @@ -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 => + ({ + id: 'connection-1', + name: 'Production', + scope: 'instance', + target: { schemaVersion: 1, remoteUrl: 'git@github.com: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>; + +describe('PromoteApplyInstanceSection', () => { + beforeEach(() => { + createTestingPinia(); + usersStore = mockedStore(useUsersStore); + usersStore.currentUser = mock({ + 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({ 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({ globalScopes: ['gitConnection:push'] }); + const { getByTestId, queryByTestId } = renderComponent({ + props: { connection: instanceConnection() }, + }); + + expect(queryByTestId('apply-instance-button')).toBeNull(); + expect(getByTestId('promote-instance-button')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.vue new file mode 100644 index 00000000000..dd9c40cf86a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteApplyInstanceSection.vue @@ -0,0 +1,135 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.test.ts deleted file mode 100644 index d408c9d8ccf..00000000000 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.test.ts +++ /dev/null @@ -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 => - ({ - id: 'connection-1', - name: 'Production', - scope: 'instance', - target: { schemaVersion: 1, remoteUrl: 'git@github.com: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>; - -describe('PromoteInstanceSection', () => { - beforeEach(() => { - createTestingPinia(); - usersStore = mockedStore(useUsersStore); - usersStore.currentUser = mock({ 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({ globalScopes: [] }); - const { queryByTestId } = renderComponent({ - props: { connection: instanceConnection() }, - }); - - expect(queryByTestId('promote-instance-button')).toBeNull(); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.vue deleted file mode 100644 index 7dfeaca9a47..00000000000 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromoteInstanceSection.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts index 5aa64f86467..06c2d871640 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.test.ts @@ -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({ globalScopes: ['gitConnection:list'] }); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue index af56c7bb91d..49d013ba6e7 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBanners.vue @@ -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' }, }); } diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts index e7a3439f914..dce05f04365 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts @@ -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[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) => { diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue index a73db9ea09b..03caee031cf 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue @@ -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 }, diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsFlow.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsFlow.vue index 4ec3367e5f1..beac23c44c9 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsFlow.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsFlow.vue @@ -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)" diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts index e52f5b3229c..e351062459a 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts @@ -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: '
' }, - Modal: { - template: ` -
- - -
- `, - }, - }, +const stubs = { + N8nDialog: { props: ['open'], template: '
' }, + Modal: { + template: ` +
+ + +
+ `, }, -}); +}; + +const renderComponent = createComponentRenderer(PromotionSelectModal, { global: { stubs } }); describe('PromotionSelectModal', () => { let pinia: ReturnType; @@ -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; + // The DTO needs at least one workflow, so every apply test starts from a full selection. + async function selectAll(findByTestId: (id: string) => Promise) { + 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: + '
{{ continueWith.kind }}:{{ continueWith.workflowIds.join(",") }}
', + }, + }, + }, + }); + 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'), diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue index 15aebf69824..858c711812f 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue @@ -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(); +// Continue must apply the selection that produced the preflight, not the live one. +const blockedWorkflowIds = shallowRef([]); 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 () => { { :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)" > @@ -481,11 +505,11 @@ onMounted(async () => { - {{ i18n.baseText('promotions.modal.incoming.applyAll') }} + {{ getApplySelectedButtonLabel() }} { v-else :open="true" :blocked-result="blockedResult" + :continue-with="{ + kind: 'selection', + projectId: props.data.projectId, + workflowIds: blockedWorkflowIds, + }" @update:open=" (open) => { if (!open) blockedResult = undefined; diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts index 8605bc382e5..8fbd03158ec 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts @@ -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 = { + 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(); 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(); 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([]); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts index fa486b68bc9..6b203b645d6 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts @@ -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; diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts index 37581b6774a..855c0a9d9ed 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts @@ -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}`, diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts index 93a07367093..c0229de8ef4 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts @@ -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 () => { diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts index 77eed635af1..d65c27b64f4 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts @@ -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([]); const commitSha = ref(null); + const source = ref(null); const isLoading = ref(false); const isSubmitting = ref(false); const error = ref(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, diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts index 89894a693f5..1e088b857e5 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts @@ -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 = ( diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts index 7146daf7d0d..bfa8dbe9ade 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts @@ -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, + }); +}); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.ts index 22105fbe824..a38f3dcdc7e 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.ts @@ -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 => + 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 => + await request({ + method: 'POST', + baseURL: context.baseUrl, + endpoint: `${promotionsApiRoot}/projects/${projectId}/apply/continue`, + data: payload, + }); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionsSettingsView.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionsSettingsView.vue index acce585fe9c..5dbed9b08f1 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionsSettingsView.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionsSettingsView.vue @@ -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" /> - +