mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
feat(editor): Add destination binding dialog (#39214)
This commit is contained in:
parent
183d769841
commit
af838bc8ea
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user