feat(editor): Add destination binding dialog (#39214)

This commit is contained in:
Kai 2026-09-23 13:04:53 +00:00 committed by GitHub
parent 183d769841
commit af838bc8ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1364 additions and 0 deletions

View File

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

View File

@ -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<MissingPromotionBinding, { kind: 'credential' }> = {
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<MissingPromotionBinding, { kind: 'variable' }> = {
kind: 'variable',
name: 'SETTING',
variableType: 'string',
scope: { kind: 'global' },
sourceValue: 'private-value',
consumers: [consumers[0]],
};
export const savedCredential: Extract<CreatedPromotionBinding, { kind: 'credential' }> = {
kind: 'credential',
sourceId: credential.sourceId,
id: credential.sourceId,
name: 'Destination credential',
credentialType: credential.credentialType,
projectId: credential.ownerProject.id,
};
export function blocked(
preflight: Partial<BlockedApplyResult['preflight']> = {},
): 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,
},
],
};

View File

@ -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<typeof renderComponent>[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<ApplyPackageResultDto>();
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();
});

View File

@ -0,0 +1,524 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue';
import {
N8nButton,
N8nCallout,
N8nDialog,
N8nDialogDescription,
N8nDialogFooter,
N8nDialogTitle,
N8nIcon,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { PromotionBindingConsumer } from '@n8n/api-types';
import { usePromotionBindings } from '../composables/usePromotionBindings';
import type {
AppliedResult,
BlockedApplyResult,
CreatedPromotionBinding,
CreatePromotionBinding,
MissingPromotionBinding,
SourceChangedResult,
} from '../promotions.types';
const props = defineProps<{
open: boolean;
blockedResult: BlockedApplyResult;
createBinding: CreatePromotionBinding;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
applied: [result: AppliedResult];
'source-changed': [result: SourceChangedResult];
'close-requested': [resources: CreatedPromotionBinding[]];
}>();
const i18n = useI18n();
const title = useTemplateRef<HTMLElement>('title');
const bindings = usePromotionBindings();
const {
preflight,
groups,
unresolvedCount,
savedResources,
isBusy,
isSubmitting,
isCreating,
sourceChanged,
error,
canContinue,
} = bindings;
watch(
() => props.open,
(open) => {
if (open) bindings.start(props.blockedResult);
else bindings.end();
},
{ immediate: true },
);
onBeforeUnmount(bindings.end);
const errorDetail = computed(() =>
error.value?.kind === 'continue' && error.value.cause instanceof Error
? error.value.cause.message
: undefined,
);
function close() {
if (!props.open || isBusy.value) return;
emit('close-requested', savedResources.value);
emit('update:open', false);
}
function preventBusyDismissal(event: Event) {
if (isBusy.value) event.preventDefault();
}
function focusTitle(event: Event) {
event.preventDefault();
void nextTick(() => title.value?.focus());
}
function destinationScope(binding: MissingPromotionBinding) {
if (binding.kind === 'credential') return binding.ownerProject.name;
return binding.scope.kind === 'global'
? i18n.baseText('promotions.bindings.global')
: binding.scope.project.name;
}
function consumerNames(consumers: PromotionBindingConsumer[]) {
return consumers
.map((consumer) =>
i18n.baseText('promotions.bindings.consumers', {
interpolate: {
project: consumer.project.name,
workflows: consumer.workflows.map((workflow) => workflow.name).join(', '),
},
}),
)
.join('; ');
}
async function create(key: string, event: MouseEvent) {
const trigger = event.currentTarget;
const row = trigger instanceof HTMLElement ? trigger.closest('tr') : null;
await bindings.createBinding(key, props.createBinding);
await nextTick();
if (!props.open || !row?.isConnected) return;
if (trigger instanceof HTMLButtonElement && trigger.isConnected && !trigger.disabled) {
trigger.focus();
} else {
row.focus();
}
}
async function continueApply() {
const result = await bindings.continueApply();
if (result?.status === 'applied') {
emit('applied', result);
emit('update:open', false);
} else if (result?.status === 'source-changed') {
emit('source-changed', result);
}
}
</script>
<template>
<N8nDialog
:open="open"
size="fit"
:show-close-button="!isBusy"
:trap-focus="!isCreating"
@open-auto-focus="focusTitle"
@update:open="close"
@escape-key-down="preventBusyDismissal"
@interact-outside="preventBusyDismissal"
>
<form :class="$style.form" @submit.prevent="continueApply">
<header :class="$style.header">
<N8nButton
type="button"
variant="ghost"
size="small"
icon="arrow-left"
icon-only
:aria-label="i18n.baseText('promotions.bindings.back')"
:disabled="isBusy"
@click="close"
/>
<div>
<N8nDialogTitle as-child>
<h2 ref="title" :class="$style.title" tabindex="-1">
{{ i18n.baseText('promotions.bindings.title') }}
</h2>
</N8nDialogTitle>
<N8nDialogDescription :class="$style.subtitle">
{{ i18n.baseText('promotions.bindings.subtitle') }}
</N8nDialogDescription>
</div>
</header>
<div :class="$style.body" data-test-id="promotion-bindings-body">
<p :class="$style.description">{{ i18n.baseText('promotions.bindings.description') }}</p>
<N8nCallout v-if="sourceChanged" theme="warning">
{{ i18n.baseText('promotions.bindings.sourceChanged') }}
</N8nCallout>
<N8nCallout v-if="error" theme="danger">
{{ i18n.baseText(`promotions.bindings.error.${error.kind}`) }}
<p v-if="errorDetail">{{ errorDetail }}</p>
</N8nCallout>
<section v-for="group in groups" :key="group.project.id" :class="$style.project">
<h3 v-if="groups.length > 1" :class="$style.projectName">{{ group.project.name }}</h3>
<section v-for="entry in group.workflows" :key="entry.workflow.id">
<h4 :class="$style.workflow">
<N8nIcon icon="workflow" size="small" />
<span>{{ entry.workflow.name }}</span>
</h4>
<div :class="$style.tableContainer">
<table :class="$style.table" :aria-label="entry.workflow.name">
<colgroup>
<col :class="$style.sourceColumn" />
<col :class="$style.destinationColumn" />
<col :class="$style.statusColumn" />
</colgroup>
<thead>
<tr>
<th scope="col">{{ i18n.baseText('promotions.bindings.source') }}</th>
<th scope="col">{{ i18n.baseText('promotions.bindings.destination') }}</th>
<th scope="col">{{ i18n.baseText('promotions.bindings.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in entry.rows" :key="row.key" tabindex="-1">
<td>
<div :class="$style.sourceItem">
<N8nIcon
:icon="row.binding.kind === 'credential' ? 'key-round' : 'json'"
size="small"
aria-hidden="false"
:aria-label="
i18n.baseText(`promotions.bindings.kind.${row.binding.kind}`)
"
/>
<span :class="$style.name">{{ row.binding.name }}</span>
</div>
</td>
<td>
<div :class="$style.destination">
<div
:class="[
$style.destinationItem,
row.status === 'missing' && $style.pendingItem,
]"
>
<span :class="$style.name">{{
row.status === 'missing'
? i18n.baseText('promotions.bindings.notCreated')
: (row.created?.name ?? row.binding.name)
}}</span>
<N8nIcon
v-if="row.status === 'missing'"
icon="circle-alert"
size="small"
/>
</div>
<N8nButton
v-if="row.status === 'missing'"
type="button"
variant="outline"
size="small"
icon="plus"
icon-only
:disabled="isBusy || sourceChanged"
:aria-label="
i18n.baseText('promotions.bindings.createNamed', {
interpolate: { name: row.binding.name },
})
"
@click="create(row.key, $event)"
/>
</div>
<div :class="$style.scope">{{ destinationScope(row.binding) }}</div>
</td>
<td aria-live="polite">
<span
:class="[
$style.status,
row.status === 'resolved' ? $style.resolved : $style.pending,
]"
>
<N8nIcon
:icon="row.status === 'resolved' ? 'circle-check' : 'circle-alert'"
size="small"
/>
{{ i18n.baseText(`promotions.bindings.status.${row.status}`) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</section>
<section v-if="preflight?.accessRequirements.length" :class="$style.notices">
<h3>{{ i18n.baseText('promotions.bindings.access.title') }}</h3>
<N8nCallout
v-for="item in preflight.accessRequirements"
:key="item.sourceId"
theme="warning"
>
<strong>{{ item.name }}</strong>
<p>{{ i18n.baseText('promotions.bindings.access.description') }}</p>
<p>{{ consumerNames(item.consumers) }}</p>
</N8nCallout>
</section>
<section v-if="preflight?.conflicts.length" :class="$style.notices">
<h3>{{ i18n.baseText('promotions.bindings.conflicts.title') }}</h3>
<N8nCallout v-for="(item, index) in preflight.conflicts" :key="index" theme="warning">
<strong>{{ item.kind === 'project' ? item.project.name : item.name }}</strong>
<p>{{ i18n.baseText(`promotions.bindings.conflicts.${item.code}`) }}</p>
<p v-if="item.kind !== 'project'">{{ consumerNames(item.consumers) }}</p>
<p v-else>{{ item.workflows.map((workflow) => workflow.name).join(', ') }}</p>
</N8nCallout>
</section>
<p v-if="preflight?.accessRequirements.length || preflight?.conflicts.length">
{{ i18n.baseText('promotions.bindings.restart') }}
</p>
<section v-if="preflight?.warnings.length" :class="$style.notices">
<h3>{{ i18n.baseText('promotions.bindings.warnings.title') }}</h3>
<N8nCallout v-for="(item, index) in preflight.warnings" :key="index" theme="info">
{{
i18n.baseText('promotions.bindings.warnings.variableShadowed', {
interpolate: { name: item.name },
})
}}
<p>{{ consumerNames(item.consumers) }}</p>
</N8nCallout>
</section>
</div>
<div :class="$style.footer">
<p v-if="savedResources.length" :class="$style.savedResources">
{{ i18n.baseText('promotions.bindings.savedResources') }}
</p>
<N8nDialogFooter :class="$style.actions">
<span :class="$style.count" role="status">
{{
i18n.baseText('promotions.bindings.unresolvedCount', {
adjustToNumber: unresolvedCount,
interpolate: { count: unresolvedCount },
})
}}
</span>
<N8nButton type="button" variant="outline" size="small" :disabled="isBusy" @click="close">
{{ i18n.baseText('promotions.bindings.close') }}
</N8nButton>
<N8nButton type="submit" size="small" :disabled="!canContinue" :loading="isSubmitting">
{{ i18n.baseText('promotions.bindings.continue') }}
</N8nButton>
</N8nDialogFooter>
</div>
</form>
</N8nDialog>
</template>
<style lang="scss" module>
.form {
display: flex;
flex-direction: column;
margin: calc(-1 * var(--spacing--lg));
// The design uses a 704 × 640 dialog. There are no matching size tokens.
width: min(44rem, calc(100dvw - var(--spacing--lg)));
height: min(40rem, calc(100dvh - var(--spacing--xl)));
color: var(--text-color);
}
.header {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
padding: var(--spacing--sm) var(--spacing--2xl) var(--spacing--sm) var(--spacing--md);
border-bottom: var(--border);
flex-shrink: 0;
}
.subtitle {
line-height: var(--line-height--xs);
font-size: var(--font-size--2xs);
color: var(--text-color--subtler);
}
.title:focus {
outline: none;
}
.body {
display: flex;
flex-direction: column;
flex: 1;
gap: var(--spacing--md);
min-height: 0;
padding: var(--spacing--md) var(--spacing--lg);
overflow: auto;
overflow-wrap: anywhere;
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
}
.description {
line-height: var(--line-height--xl);
color: var(--text-color--subtle);
}
.project,
.notices {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.projectName {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
color: var(--text-color--subtler);
}
.workflow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--2xs);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
svg {
color: var(--icon-color);
}
}
.tableContainer {
border: var(--border);
border-radius: var(--radius--xs);
overflow: hidden;
}
.table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
th {
padding: var(--spacing--2xs) var(--spacing--xs);
background: var(--background--subtle);
color: var(--text-color--subtle);
font-size: var(--font-size--3xs);
font-weight: var(--font-weight--regular);
}
th,
td {
text-align: left;
vertical-align: middle;
}
td {
padding: var(--spacing--2xs) var(--spacing--xs);
border-top: var(--border);
}
}
.sourceColumn,
.destinationColumn {
width: 40%;
}
.statusColumn {
width: 20%;
}
.sourceItem,
.destination,
.destinationItem,
.status {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
svg {
flex-shrink: 0;
}
}
.sourceItem {
color: var(--text-color--subtle);
}
.sourceItem svg {
color: var(--icon-color);
}
.name {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.destinationItem {
flex: 1;
justify-content: space-between;
padding: var(--spacing--3xs) var(--spacing--2xs);
border: var(--border);
border-radius: var(--radius--3xs);
background: var(--background--subtle);
}
.pendingItem {
border-color: var(--border-color--warning);
color: var(--text-color--warning);
}
.scope {
margin-top: var(--spacing--4xs);
color: var(--text-color--subtler);
font-size: var(--font-size--3xs);
}
.status {
font-size: var(--font-size--3xs);
line-height: var(--line-height--xl);
}
.pending {
color: var(--text-color--warning);
}
.resolved {
color: var(--text-color--success);
}
.notices h3 {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
}
.footer {
flex-shrink: 0;
padding: var(--spacing--sm);
border-top: var(--border);
}
.savedResources {
margin-bottom: var(--spacing--xs);
color: var(--text-color--subtle);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
}
.actions {
margin-top: 0;
}
.count {
margin-right: auto;
align-self: center;
color: var(--text-color--subtle);
font-size: var(--font-size--3xs);
}
</style>

View File

@ -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<MissingPromotionBinding, { kind: 'variable' }> = {
...variable,
scope: { kind: 'project', project: consumers[0].project },
};
const otherProjectVariable: Extract<MissingPromotionBinding, { kind: 'variable' }> = {
...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<CreatedPromotionBinding | null>();
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<ApplyPackageResultDto>();
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<CreatedPromotionBinding | null>();
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);
});

View File

@ -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<BlockedApplyResult>();
const preflight = shallowRef<BlockedApplyResult['preflight']>();
const knownBindings = shallowRef(new Map<string, MissingPromotionBinding>());
const missingKeys = shallowRef(new Set<string>());
// Binding key to the consumer project IDs that still need access to it.
const accessByKey = shallowRef(new Map<string, Set<string>>());
const createdBindings = shallowRef(new Map<string, CreatedPromotionBinding>());
const isSubmitting = ref(false);
const isCreating = ref(false);
const isFinished = ref(false);
const sourceChanged = ref(false);
const error = shallowRef<PromotionBindingsError | null>(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<string, BindingGroup>();
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,
};
}

View File

@ -0,0 +1,27 @@
import type { ApplyPackageResultDto, PromotionVariableScope } from '@n8n/api-types';
export type BlockedApplyResult = Extract<ApplyPackageResultDto, { status: 'blocked' }>;
export type AppliedResult = Extract<ApplyPackageResultDto, { status: 'applied' }>;
export type SourceChangedResult = Extract<ApplyPackageResultDto, { status: 'source-changed' }>;
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<CreatedPromotionBinding | null>;

View File

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

View File

@ -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<ApplyPackageResultDto> =>
await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply/continue`,
data: payload,
});