From af838bc8ea2b161110330946e4dcd63ff43b63ff Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 23 Sep 2026 13:04:53 +0000 Subject: [PATCH] feat(editor): Add destination binding dialog (#39214) --- .../frontend/@n8n/i18n/src/locales/en.json | 37 ++ .../__tests__/bindings.fixtures.ts | 90 +++ .../PromotionBindingsDialog.test.ts | 168 ++++++ .../components/PromotionBindingsDialog.vue | 524 ++++++++++++++++++ .../composables/usePromotionBindings.test.ts | 251 +++++++++ .../composables/usePromotionBindings.ts | 233 ++++++++ .../promotions.ee/promotions.types.ts | 27 + .../promotionsSettings.api.test.ts | 20 + .../promotions.ee/promotionsSettings.api.ts | 14 + 9 files changed, 1364 insertions(+) create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/bindings.fixtures.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts create mode 100644 packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6d6e6b9b609..415a26b8b3d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8881,6 +8881,43 @@ "promotions.banner.changesAvailable": "{count} changes available to promote.", "promotions.banner.singleChangeAvailable": "1 change available to promote.", "promotions.banner.viewChanges": "View changes", + "promotions.bindings.title": "Resolve bindings", + "promotions.bindings.subtitle": "Set up incoming credentials and variables", + "promotions.bindings.back": "Back", + "promotions.bindings.notCreated": "Not created", + "promotions.bindings.description": "Create the missing credentials and variables to continue. Each item is created in the destination scope shown below when you save it.", + "promotions.bindings.source": "Source credential/variable", + "promotions.bindings.destination": "Destination credential/variable", + "promotions.bindings.status": "Status", + "promotions.bindings.kind.credential": "Credential", + "promotions.bindings.kind.variable": "Variable", + "promotions.bindings.global": "Global", + "promotions.bindings.status.resolved": "Resolved", + "promotions.bindings.status.missing": "Needs setup", + "promotions.bindings.status.access": "Access required", + "promotions.bindings.createNamed": "Create {name}", + "promotions.bindings.close": "Close", + "promotions.bindings.continue": "Continue", + "promotions.bindings.unresolvedCount": "All items are ready | 1 item still needs setup | {count} items still need setup", + "promotions.bindings.consumers": "{project}: {workflows}", + "promotions.bindings.savedResources": "Closing keeps the credentials and variables you saved. It does not import workflows.", + "promotions.bindings.sourceChanged": "The source changed. Start Apply remote changes again to confirm the latest source.", + "promotions.bindings.error.creationMismatch": "The saved item does not match the required binding. Check its identity and destination scope before you continue.", + "promotions.bindings.error.continue": "Apply could not be completed. Check the destination before you try again. Some changes may already have been applied.", + "promotions.bindings.access.title": "Access required", + "promotions.bindings.access.description": "The credential exists but is not available to these workflows. Ask an administrator to give their projects access.", + "promotions.bindings.conflicts.title": "Conflicts", + "promotions.bindings.conflicts.missing-id": "The source credential has no ID. Correct the credential reference in the source.", + "promotions.bindings.conflicts.conflicting-types": "The source uses different credential types for the same ID. Correct the source references to use one type.", + "promotions.bindings.conflicts.unknown-type": "The credential type is not installed. Install the required node on the destination.", + "promotions.bindings.conflicts.unknown-owner": "The credential has no valid owner project. Set its owner project in the source.", + "promotions.bindings.conflicts.type-mismatch": "The destination credential has a different type. Correct the credential type before you apply.", + "promotions.bindings.conflicts.incompatible-usage-scope": "The credential scope does not support these workflows. Correct the destination credential scope.", + "promotions.bindings.conflicts.missing-definition": "The source variable has no definition. Add its definition in the source.", + "promotions.bindings.conflicts.project-not-team": "The destination project is not a team project. Correct the project in the source or destination.", + "promotions.bindings.restart": "After you correct access or conflicts, close this dialog and start Apply remote changes again.", + "promotions.bindings.warnings.title": "Warnings", + "promotions.bindings.warnings.variableShadowed": "A project variable overrides the global variable {name} for these workflows. You can continue.", "promotions.banner.incoming.changesAvailable": "{count} incoming changes.", "promotions.banner.incoming.singleChangeAvailable": "1 incoming change.", "promotions.banner.incoming.error": "Could not check for incoming changes.", diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/bindings.fixtures.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/bindings.fixtures.ts new file mode 100644 index 00000000000..a796ab44f51 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/__tests__/bindings.fixtures.ts @@ -0,0 +1,90 @@ +import type { + AppliedResult, + BlockedApplyResult, + CreatedPromotionBinding, + MissingPromotionBinding, +} from '../promotions.types'; + +export const consumers = [ + { project: { id: 'team-a', name: 'Team A' }, workflows: [{ id: 'wf-a', name: 'Workflow A' }] }, + { project: { id: 'team-b', name: 'Team B' }, workflows: [{ id: 'wf-b', name: 'Workflow B' }] }, +]; + +export const credential: Extract = { + kind: 'credential', + sourceId: 'credential-id', + name: 'Source credential', + credentialType: 'httpHeaderAuth', + ownerProject: { id: 'owner-team', name: 'Owner team' }, + expressionData: { value: '={{ $vars.SECRET }}' }, + consumers, +}; + +export const variable: Extract = { + kind: 'variable', + name: 'SETTING', + variableType: 'string', + scope: { kind: 'global' }, + sourceValue: 'private-value', + consumers: [consumers[0]], +}; + +export const savedCredential: Extract = { + kind: 'credential', + sourceId: credential.sourceId, + id: credential.sourceId, + name: 'Destination credential', + credentialType: credential.credentialType, + projectId: credential.ownerProject.id, +}; + +export function blocked( + preflight: Partial = {}, +): BlockedApplyResult { + return { + status: 'blocked', + connectionId: 'connection-id', + configId: 'config-id', + git: { branchName: 'main', commitSha: 'a'.repeat(40) }, + preflight: { + missingProjects: [], + missingBindings: [credential], + accessRequirements: [], + conflicts: [], + warnings: [], + ...preflight, + }, + }; +} + +export const applied: AppliedResult = { + status: 'applied', + connectionId: 'connection-id', + configId: 'config-id', + git: { branchName: 'main', commitSha: 'a'.repeat(40) }, + counts: { + projects: { created: 0, updated: 1, skipped: 1, deleted: 0 }, + folders: { created: 0, skipped: 0, removed: 0 }, + workflows: { + created: 1, + updated: 2, + skipped: 1, + archived: 0, + deleted: 0, + publishing: { published: 0, unpublished: 0, unchanged: 0, blocked: 1, failed: 1 }, + }, + credentials: { matched: 1, stubbed: 0 }, + dataTables: { matched: 0, created: 0 }, + variables: { matched: 0, created: 0, updated: 0, stubbed: 0, missing: 0 }, + tags: { matched: 0, created: 0, renamed: 0, reconciled: 0, skipped: 0 }, + }, + warnings: [ + { + kind: 'variable', + code: 'variable-shadowed', + name: 'SETTING', + scope: { kind: 'global' }, + consumers, + }, + ], +}; 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 new file mode 100644 index 00000000000..b5e46810099 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.test.ts @@ -0,0 +1,168 @@ +import { createComponentRenderer } from '@n8n/frontend-test-utils'; +import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; +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 { + applied, + blocked, + consumers, + credential, + savedCredential, + variable, +} from '../__tests__/bindings.fixtures'; + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ publicApiContext: { baseUrl: '/custom/api/v1' } }), +})); +vi.mock('../promotionsSettings.api'); + +const renderComponent = createComponentRenderer(PromotionBindingsDialog); + +async function renderDialog(options: Parameters[0]) { + const result = renderComponent(options); + await result.findByRole('dialog'); + return result; +} + +beforeEach(() => vi.resetAllMocks()); + +it('shows all projects, scopes, and blocker reasons without exposing source values', async () => { + const { getByRole, getByText, getAllByText, queryByText } = await renderDialog({ + props: { + open: true, + blockedResult: blocked({ + missingBindings: [credential, variable], + accessRequirements: [ + { + ...credential, + sourceId: 'restricted-credential', + name: 'Restricted credential', + code: 'access-required', + }, + ], + conflicts: [ + { + kind: 'project', + code: 'project-not-team', + project: { id: 'personal', name: 'Personal project' }, + filePath: 'project.json', + workflows: [], + }, + ], + warnings: applied.warnings, + }), + createBinding: vi.fn(), + }, + }); + expect(getByRole('heading', { name: 'Team A' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Team B' })).toBeInTheDocument(); + expect(getByRole('table', { name: 'Workflow A' })).toBeInTheDocument(); + expect(getByRole('table', { name: 'Workflow B' })).toBeInTheDocument(); + expect(getAllByText('Owner team')).toHaveLength(2); + expect(getByText('Global')).toBeInTheDocument(); + expect(getAllByText('Needs setup')).toHaveLength(3); + expect(getByText(/Ask an administrator/)).toBeInTheDocument(); + expect(getByText(/destination project is not a team project/)).toBeInTheDocument(); + expect(getByText(/project variable overrides the global variable SETTING/)).toBeInTheDocument(); + expect(queryByText('private-value')).not.toBeInTheDocument(); + expect(queryByText('={{ $vars.SECRET }}')).not.toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect(getByRole('status')).toHaveTextContent('4 items still need setup'); +}); + +it('creates the original binding, restores focus, and emits the full applied result', async () => { + const createBinding = vi.fn().mockResolvedValue(savedCredential); + vi.mocked(continueApplyPromotion).mockResolvedValue(applied); + const { getAllByRole, getByRole, getAllByText, emitted } = await renderDialog({ + props: { open: true, blockedResult: blocked(), createBinding }, + }); + const create = getAllByRole('button', { name: 'Create Source credential' })[0]; + const row = create.closest('tr'); + await userEvent.click(create); + expect(createBinding.mock.calls[0][0]).toStrictEqual(credential); + expect(getAllByText('Destination credential')).toHaveLength(2); + expect(getAllByText('Resolved')).toHaveLength(2); + expect(row).toHaveFocus(); + await userEvent.click(getByRole('button', { name: 'Continue' })); + expect(emitted('applied')).toEqual([[applied]]); + expect(emitted('update:open')).toEqual([[false]]); + expect(emitted('close-requested')).toBeUndefined(); +}); + +it.each(['Close', 'Close dialog', 'Back', 'Escape'] as const)( + 'keeps saved resources when dismissed with %s', + async (action) => { + const { getAllByRole, getByRole, getByText, emitted } = await renderDialog({ + props: { + open: true, + blockedResult: blocked(), + createBinding: vi.fn().mockResolvedValue(savedCredential), + }, + }); + await userEvent.click(getAllByRole('button', { name: 'Create Source credential' })[0]); + expect(getByText(/Closing keeps the credentials and variables you saved/)).toBeInTheDocument(); + if (action === 'Escape') await userEvent.keyboard('{Escape}'); + else await userEvent.click(getByRole('button', { name: action })); + expect(emitted('close-requested')).toEqual([[[savedCredential]]]); + expect(emitted('update:open')).toEqual([[false]]); + expect(continueApplyPromotion).not.toHaveBeenCalled(); + }, +); + +it('blocks dismissal and repeated form submissions while Continue is pending', async () => { + const pending = createDeferredPromise(); + vi.mocked(continueApplyPromotion).mockReturnValue(pending.promise); + const { getByRole, queryByRole, emitted } = await renderDialog({ + props: { open: true, blockedResult: blocked({ missingBindings: [] }), createBinding: vi.fn() }, + }); + const button = getByRole('button', { name: 'Continue' }); + await userEvent.click(button); + const form = button.closest('form'); + expect(form).not.toBeNull(); + await fireEvent.submit(form!); + await userEvent.keyboard('{Escape}'); + expect(getByRole('button', { name: 'Close' })).toBeDisabled(); + expect(queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument(); + expect(emitted('update:open')).toBeUndefined(); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); + pending.resolve(applied); + await waitFor(() => expect(emitted('applied')).toEqual([[applied]])); +}); + +it('returns a changed source to the caller and stops the session', async () => { + const initial = blocked({ missingBindings: [] }); + const result = { + status: 'source-changed' as const, + connectionId: initial.connectionId, + configId: initial.configId, + git: { branchName: 'main', commitSha: 'b'.repeat(40) }, + }; + vi.mocked(continueApplyPromotion).mockResolvedValue(result); + const { getByRole, getByText, emitted } = await renderDialog({ + props: { open: true, blockedResult: initial, createBinding: vi.fn() }, + }); + await userEvent.click(getByRole('button', { name: 'Continue' })); + expect(emitted('source-changed')).toEqual([[result]]); + expect(getByText(/The source changed/)).toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); +}); + +it('shows recovery guidance after a request fails and keeps the saved row', async () => { + vi.mocked(continueApplyPromotion).mockRejectedValue(new Error('Offline')); + const { getByRole, getByText } = await renderDialog({ + props: { + open: true, + blockedResult: blocked({ missingBindings: [{ ...credential, consumers: [consumers[0]] }] }), + createBinding: vi.fn().mockResolvedValue(savedCredential), + }, + }); + await userEvent.click(getByRole('button', { name: 'Create Source credential' })); + await userEvent.click(getByRole('button', { name: 'Continue' })); + expect(getByText(/Check the destination before you try again/)).toBeInTheDocument(); + expect(within(getByRole('table')).getByText('Resolved')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue' })).toBeEnabled(); +}); 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 new file mode 100644 index 00000000000..11ab62c49c8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionBindingsDialog.vue @@ -0,0 +1,524 @@ + + + + + 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 new file mode 100644 index 00000000000..8605bc382e5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.test.ts @@ -0,0 +1,251 @@ +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 { + applied, + blocked, + consumers, + credential, + savedCredential, + variable, +} from '../__tests__/bindings.fixtures'; +import type { CreatedPromotionBinding, MissingPromotionBinding } from '../promotions.types'; + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ publicApiContext: { baseUrl: '/custom/api/v1' } }), +})); +vi.mock('../promotionsSettings.api'); + +beforeEach(() => vi.resetAllMocks()); + +it('resolves shared credentials once and keeps variable scopes separate', async () => { + const projectVariable: Extract = { + ...variable, + scope: { kind: 'project', project: consumers[0].project }, + }; + const otherProjectVariable: Extract = { + ...variable, + scope: { kind: 'project', project: consumers[1].project }, + }; + const session = usePromotionBindings(); + const result = blocked({ + missingBindings: [credential, variable, projectVariable, otherProjectVariable], + }); + session.start(result); + 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']); + const create = vi.fn().mockResolvedValue(savedCredential); + await session.createBinding(promotionBindingKey(credential), create); + expect(create.mock.calls[0][0]).toBe(credential); + expect(session.unresolvedCount.value).toBe(3); + expect(session.groups.value.map((group) => group.workflows[0].rows[0].status)).toEqual([ + 'resolved', + 'resolved', + ]); + await session.createBinding(promotionBindingKey(projectVariable), async () => ({ + kind: 'variable', + id: 'var-1', + name: variable.name, + scope: projectVariable.scope, + })); + expect(session.unresolvedCount.value).toBe(2); + expect(session.savedResources.value).toEqual([ + savedCredential, + { kind: 'variable', id: 'var-1', name: variable.name, scope: projectVariable.scope }, + ]); +}); + +it.each([ + ['cancelled', null], + ['wrong source ID', { ...savedCredential, sourceId: 'other' }], + ['wrong saved ID', { ...savedCredential, id: 'other' }], + ['wrong owner', { ...savedCredential, projectId: 'team-a' }], + ['wrong type', { ...savedCredential, credentialType: 'other' }], +] as const)('keeps a credential unresolved when creation returns %s', async (_label, result) => { + const session = usePromotionBindings(); + session.start(blocked()); + await session.createBinding(promotionBindingKey(credential), async () => result); + expect(session.unresolvedCount.value).toBe(1); + expect(session.savedResources.value).toEqual([]); + expect(session.canContinue.value).toBe(false); +}); + +it('keeps global variables unresolved after a project variable is saved', async () => { + const session = usePromotionBindings(); + session.start(blocked({ missingBindings: [variable] })); + await session.createBinding(promotionBindingKey(variable), async (item) => { + expect(item).toBe(variable); + return { + kind: 'variable', + name: variable.name, + id: 'var-id', + scope: { kind: 'project', project: consumers[0].project }, + }; + }); + expect(session.error.value).toEqual({ kind: 'creationMismatch' }); + expect(session.canContinue.value).toBe(false); +}); + +it('guards creation and Continue while the editor is open and recovers after failure', async () => { + const pending = createDeferredPromise(); + const session = usePromotionBindings(); + session.start(blocked()); + const create = vi.fn().mockReturnValue(pending.promise); + const first = session.createBinding(promotionBindingKey(credential), create); + await session.createBinding(promotionBindingKey(credential), create); + await session.continueApply(); + expect(create).toHaveBeenCalledTimes(1); + expect(continueApplyPromotion).not.toHaveBeenCalled(); + expect(session.isCreating.value).toBe(true); + pending.reject(new Error('Save failed')); + await first; + expect(session.error.value).toBeNull(); + expect(session.isCreating.value).toBe(false); + expect(session.unresolvedCount.value).toBe(1); +}); + +it.each(['access', 'conflict'] as const)('blocks Continue for %s requirements', async (kind) => { + const session = usePromotionBindings(); + session.start( + blocked({ + missingBindings: [], + accessRequirements: kind === 'access' ? [{ ...credential, code: 'access-required' }] : [], + conflicts: + kind === 'conflict' + ? [ + { + kind: 'variable', + name: variable.name, + code: 'missing-definition', + referenceFiles: ['workflow.json'], + consumers, + }, + ] + : [], + }), + ); + expect(session.canContinue.value).toBe(false); + expect(session.unresolvedCount.value).toBe(1); + await session.continueApply(); + expect(continueApplyPromotion).not.toHaveBeenCalled(); +}); + +it('counts credentials with the same name and different types separately', () => { + const session = usePromotionBindings(); + session.start( + blocked({ + missingBindings: [], + conflicts: ['githubApi', 'gitlabApi'].map((type) => ({ + kind: 'credential', + code: 'missing-id', + sourceId: null, + name: 'Production', + expectedTypes: [type], + consumers, + referenceFiles: ['workflow.json'], + })), + }), + ); + expect(session.unresolvedCount.value).toBe(2); + expect(session.canContinue.value).toBe(false); +}); + +it('submits once with the original source and allows warnings', async () => { + const pending = createDeferredPromise(); + vi.mocked(continueApplyPromotion).mockReturnValue(pending.promise); + const session = usePromotionBindings(); + const result = blocked({ missingBindings: [], warnings: applied.warnings }); + session.start(result); + expect(session.canContinue.value).toBe(true); + const first = session.continueApply(); + await session.continueApply(); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); + expect(continueApplyPromotion).toHaveBeenCalledWith( + { baseUrl: '/custom/api/v1' }, + result.connectionId, + { expectedSource: { configId: result.configId, ...result.git } }, + ); + pending.resolve(applied); + expect(await first).toBe(applied); + expect(session.canContinue.value).toBe(false); + await session.continueApply(); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); +}); + +it('replaces blockers and preserves saved rows after another blocked result', async () => { + const session = usePromotionBindings(); + const result = blocked({ missingBindings: [credential, variable] }); + session.start(result); + await session.createBinding(promotionBindingKey(credential), async () => savedCredential); + await session.createBinding(promotionBindingKey(variable), async () => ({ + kind: 'variable', + id: 'var-id', + name: variable.name, + scope: variable.scope, + })); + const next = blocked({ + accessRequirements: [{ ...credential, sourceId: 'other-credential', code: 'access-required' }], + warnings: applied.warnings, + }); + vi.mocked(continueApplyPromotion).mockResolvedValue(next); + await session.continueApply(); + expect(session.preflight.value).toBe(next.preflight); + expect(session.originalResult.value).toBe(result); + expect(session.unresolvedCount.value).toBe(2); + const rows = session.groups.value[0].workflows[0].rows; + expect(rows.find((row) => row.binding.kind === 'credential')?.status).toBe('missing'); + expect(rows.find((row) => row.binding.kind === 'variable')?.status).toBe('resolved'); + expect(session.savedResources.value).toHaveLength(2); + expect(session.canContinue.value).toBe(false); +}); + +it('stops after a source change without retrying', async () => { + const session = usePromotionBindings(); + const initial = blocked({ missingBindings: [] }); + session.start(initial); + const changed = { + ...initial, + status: 'source-changed' as const, + git: { branchName: 'main', commitSha: 'b'.repeat(40) }, + }; + vi.mocked(continueApplyPromotion).mockResolvedValue(changed); + expect(await session.continueApply()).toBe(changed); + expect(session.sourceChanged.value).toBe(true); + expect(session.originalResult.value).toBe(initial); + await session.continueApply(); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); +}); + +it.each([new Error('Offline'), new ResponseError('Forbidden', { httpStatusCode: 403 })])( + 'keeps saved items after a request fails: %s', + async (error) => { + const session = usePromotionBindings(); + session.start(blocked()); + await session.createBinding(promotionBindingKey(credential), async () => savedCredential); + vi.mocked(continueApplyPromotion).mockRejectedValue(error); + await session.continueApply(); + expect(session.error.value).toEqual({ kind: 'continue', cause: error }); + expect(session.savedResources.value).toEqual([savedCredential]); + expect(session.canContinue.value).toBe(true); + expect(continueApplyPromotion).toHaveBeenCalledTimes(1); + }, +); + +it('ignores an editor result from a closed session', async () => { + const pending = createDeferredPromise(); + const session = usePromotionBindings(); + session.start(blocked()); + const creating = session.createBinding( + promotionBindingKey(credential), + async () => await pending.promise, + ); + session.end(); + session.start(blocked()); + pending.resolve(savedCredential); + await creating; + expect(session.savedResources.value).toEqual([]); + expect(session.unresolvedCount.value).toBe(1); +}); 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 new file mode 100644 index 00000000000..fa486b68bc9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionBindings.ts @@ -0,0 +1,233 @@ +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 type { + BlockedApplyResult, + CreatedPromotionBinding, + CreatePromotionBinding, + MissingPromotionBinding, +} from '../promotions.types'; + +export function promotionBindingKey(binding: MissingPromotionBinding | CreatedPromotionBinding) { + if (binding.kind === 'credential') return JSON.stringify([binding.kind, binding.sourceId]); + return JSON.stringify([ + binding.kind, + binding.name, + binding.scope.kind, + binding.scope.kind === 'project' ? binding.scope.project.id : null, + ]); +} + +// Only a missing binding can be created here. Access is granted outside the dialog. +type BindingStatus = 'missing' | 'access' | 'resolved'; + +type BindingRow = { + key: string; + binding: MissingPromotionBinding; + status: BindingStatus; + created?: CreatedPromotionBinding; +}; + +type PromotionBindingsError = + | { kind: 'creationMismatch' } + // The UI uses the cause to show why Continue failed. + | { kind: 'continue'; cause: unknown }; + +type BindingGroup = { + project: PromotionBindingConsumer['project']; + workflows: Array<{ + workflow: PromotionBindingConsumer['workflows'][number]; + rows: BindingRow[]; + }>; +}; + +function matchesCreation(binding: MissingPromotionBinding, created: CreatedPromotionBinding) { + if (!created.id || promotionBindingKey(binding) !== promotionBindingKey(created)) return false; + if (binding.kind === 'credential' && created.kind === 'credential') { + return ( + created.id === binding.sourceId && + created.credentialType === binding.credentialType && + created.projectId === binding.ownerProject.id + ); + } + return binding.kind === 'variable' && created.kind === 'variable'; +} + +export function usePromotionBindings() { + const rootStore = useRootStore(); + const originalResult = shallowRef(); + const preflight = shallowRef(); + const knownBindings = shallowRef(new Map()); + const missingKeys = shallowRef(new Set()); + // Binding key to the consumer project IDs that still need access to it. + const accessByKey = shallowRef(new Map>()); + const createdBindings = shallowRef(new Map()); + const isSubmitting = ref(false); + const isCreating = ref(false); + const isFinished = ref(false); + const sourceChanged = ref(false); + const error = shallowRef(null); + let session = 0; + let expectedSource: ContinueApplyPackageDto['expectedSource'] | undefined; + let connectionId: string | undefined; + + function statusOf(key: string, projectId: string): BindingStatus { + if (missingKeys.value.has(key)) return 'missing'; + if (accessByKey.value.get(key)?.has(projectId)) return 'access'; + return 'resolved'; + } + + const groups = computed(() => { + const projects = new Map(); + for (const [key, binding] of knownBindings.value) { + for (const consumer of binding.consumers) { + let group = projects.get(consumer.project.id); + if (!group) { + group = { project: consumer.project, workflows: [] }; + projects.set(consumer.project.id, group); + } + const row: BindingRow = { + key, + binding, + status: statusOf(key, consumer.project.id), + created: createdBindings.value.get(key), + }; + for (const workflow of consumer.workflows) { + let workflowGroup = group.workflows.find((entry) => entry.workflow.id === workflow.id); + if (!workflowGroup) { + workflowGroup = { workflow, rows: [] }; + group.workflows.push(workflowGroup); + } + if (!workflowGroup.rows.some((entry) => entry.key === row.key)) { + workflowGroup.rows.push(row); + } + } + } + } + return Array.from(projects.values()); + }); + + const unresolvedCount = computed( + () => + missingKeys.value.size + accessByKey.value.size + (preflight.value?.conflicts.length ?? 0), + ); + const isBusy = computed(() => isSubmitting.value || isCreating.value); + const canContinue = computed( + () => + !!originalResult.value && + unresolvedCount.value === 0 && + !isBusy.value && + !sourceChanged.value && + !isFinished.value, + ); + const savedResources = computed(() => Array.from(createdBindings.value.values())); + + function reconcile(result: BlockedApplyResult) { + preflight.value = result.preflight; + const next = new Map(knownBindings.value); + for (const binding of result.preflight.missingBindings) { + next.set(promotionBindingKey(binding), binding); + } + knownBindings.value = next; + missingKeys.value = new Set(result.preflight.missingBindings.map(promotionBindingKey)); + accessByKey.value = new Map( + result.preflight.accessRequirements.map((access) => [ + JSON.stringify([access.kind, access.sourceId]), + new Set(access.consumers.map((consumer) => consumer.project.id)), + ]), + ); + } + + function start(result: BlockedApplyResult) { + session++; + originalResult.value = result; + connectionId = result.connectionId; + expectedSource = { configId: result.configId, ...result.git }; + knownBindings.value = new Map(); + createdBindings.value = new Map(); + isSubmitting.value = false; + isCreating.value = false; + isFinished.value = false; + sourceChanged.value = false; + error.value = null; + reconcile(result); + } + + function end() { + session++; + originalResult.value = undefined; + } + + async function createBinding(key: string, create: CreatePromotionBinding) { + const binding = knownBindings.value.get(key); + if ( + !originalResult.value || + !binding || + !missingKeys.value.has(key) || + isBusy.value || + sourceChanged.value || + isFinished.value + ) + return; + const currentSession = session; + isCreating.value = true; + error.value = null; + try { + const created = await create(binding); + if (currentSession !== session || !created) return; + if (!matchesCreation(binding, created)) { + error.value = { kind: 'creationMismatch' }; + return; + } + createdBindings.value = new Map(createdBindings.value).set(key, created); + const next = new Set(missingKeys.value); + next.delete(key); + missingKeys.value = next; + } catch { + // The adapter reports its own failures. + } finally { + if (currentSession === session) isCreating.value = false; + } + } + + async function continueApply() { + if (!canContinue.value || !connectionId || !expectedSource) return; + const currentSession = session; + isSubmitting.value = true; + error.value = null; + try { + const result = 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; + if (result.status === 'applied') isFinished.value = true; + return result; + } catch (cause) { + if (currentSession === session) error.value = { kind: 'continue', cause }; + return undefined; + } finally { + if (currentSession === session) isSubmitting.value = false; + } + } + + return { + originalResult, + preflight, + groups, + unresolvedCount, + savedResources, + isBusy, + isSubmitting, + isCreating, + sourceChanged, + error, + canContinue, + start, + end, + createBinding, + continueApply, + }; +} 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 new file mode 100644 index 00000000000..9ede5885abf --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotions.types.ts @@ -0,0 +1,27 @@ +import type { ApplyPackageResultDto, PromotionVariableScope } from '@n8n/api-types'; + +export type BlockedApplyResult = Extract; +export type AppliedResult = Extract; +export type SourceChangedResult = Extract; +export type MissingPromotionBinding = BlockedApplyResult['preflight']['missingBindings'][number]; + +export type CreatedPromotionBinding = + | { + kind: 'credential'; + sourceId: string; + id: string; + name: string; + credentialType: string; + projectId: string; + } + | { + kind: 'variable'; + id: string; + name: string; + scope: PromotionVariableScope; + }; + +// Resolve after the editor closes. Return null on cancellation. Report a failure, then reject. +export type CreatePromotionBinding = ( + binding: MissingPromotionBinding, +) => Promise; 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 new file mode 100644 index 00000000000..7146daf7d0d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/promotionsSettings.api.test.ts @@ -0,0 +1,20 @@ +import { request } from '@n8n/rest-api-client'; +import { continueApplyPromotion } from './promotionsSettings.api'; +import { blocked } from './__tests__/bindings.fixtures'; + +vi.mock('@n8n/rest-api-client', () => ({ request: vi.fn() })); + +it('uses the public API context and returns the complete result', async () => { + const result = blocked(); + const context = { baseUrl: '/custom/api/v2' }; + vi.mocked(request).mockResolvedValue(result); + + const body = { expectedSource: { configId: result.configId, ...result.git } }; + expect(await continueApplyPromotion(context, result.connectionId, body)).toBe(result); + expect(request).toHaveBeenLastCalledWith({ + method: 'POST', + baseURL: context.baseUrl, + endpoint: '/promotions/connections/connection-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 09386746e0c..22105fbe824 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,6 +1,7 @@ import type { ApplyPackageDto, ApplyPackageResultDto, + ContinueApplyPackageDto, CreatePromotionConnectionDto, CreatePromotionProviderDto, PromotionApplyConfigPublicDto, @@ -237,3 +238,16 @@ export const applyPromotion = async ( endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply`, data: payload, }); + +/** Resumes an apply that paused on unresolved bindings, once they are created. */ +export const continueApplyPromotion = async ( + context: PublicApiContext, + connectionId: string, + payload: ContinueApplyPackageDto, +): Promise => + await request({ + method: 'POST', + baseURL: context.baseUrl, + endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply/continue`, + data: payload, + });