feat(editor): Add a dialog to move a workflow to a linked instance (no-changelog)

The workflow menu shows "Move to {name}" for each linked instance.
The dialog checks what moves, shows the problems, and moves the saved workflow.
A message then shows the state of the copy and the credentials to set up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh
This commit is contained in:
Claude 2026-10-09 04:52:16 +00:00
parent 6a60b3aa7f
commit e0cd96be33
No known key found for this signature in database
25 changed files with 3867 additions and 2 deletions

View File

@ -3704,6 +3704,59 @@
"settings.opentelemetry.unsavedChanges.cancel": "Cancel",
"settings.opentelemetry.unsavedChanges.leaveWithoutSaving": "Leave without saving",
"settings.opentelemetry.unsavedChanges.saveAndLeave": "Save and leave",
"linkedInstances.transfer.menu.moveTo": "Move to {name}",
"linkedInstances.transfer.unsaved.title": "Save your changes before moving?",
"linkedInstances.transfer.unsaved.message": "{place} gets the saved version of this workflow. Save your changes so that they move too.",
"linkedInstances.transfer.unsaved.confirm": "Save and continue",
"linkedInstances.transfer.title": "Move \"{name}\" to {place}?",
"linkedInstances.transfer.description.fromAssistant": "This chat stays here. The workflow moves.",
"linkedInstances.transfer.checking": "Checking what moves…",
"linkedInstances.transfer.checkFailed": "Couldn't check what moves to {place}. Try again.",
"linkedInstances.transfer.retry": "Try again",
"linkedInstances.transfer.details": "Move details",
"linkedInstances.transfer.status.checked": "Check done. 1 node moves to {place}. | Check done. {count} nodes move to {place}.",
"linkedInstances.transfer.status.needsSetUp": "1 credential needs setting up. | {count} credentials need setting up.",
"linkedInstances.transfer.status.checkedNeedsSetUp": "{checked} {setUp}",
"linkedInstances.transfer.status.cannotMove": "Check done. The workflow can't move to {place}.",
"linkedInstances.transfer.status.moving": "Moving the workflow to {place}…",
"linkedInstances.transfer.moves.heading": "Moves",
"linkedInstances.transfer.moves.nodes": "1 node | {count} nodes",
"linkedInstances.transfer.moves.project": "Goes to the project {project}",
"linkedInstances.transfer.moves.personalProject": "Goes to your personal project",
"linkedInstances.transfer.moves.matchedCredentials": "Uses the credential with the same name in {place}: {names} | Uses the credentials with the same names in {place}: {names}",
"linkedInstances.transfer.setUp.heading": "Needs setting up",
"linkedInstances.transfer.setUp.empty": "Arrives empty",
"linkedInstances.transfer.setUp.unknown": "Not checked",
"linkedInstances.transfer.setUp.row": "{name}: {status}",
"linkedInstances.transfer.cannotMove.heading": "Can't move",
"linkedInstances.transfer.cannotMove.nodeTypes": "{place} doesn't have these node types:",
"linkedInstances.transfer.cannotMove.subWorkflows": "This workflow calls other workflows by ID, and a move copies one workflow only:",
"linkedInstances.transfer.cannotMove.hiddenWorkflow": "A workflow you can't open (ID {id})",
"linkedInstances.transfer.warning.credentialsUnchecked": "n8n couldn't check the credentials in {place}. Some may need setting up after the move.",
"linkedInstances.transfer.copyHere.heading": "The copy here",
"linkedInstances.transfer.copyHere.turnOff": "Turn off the copy on this computer",
"linkedInstances.transfer.copyThere.heading": "The copy in {place}",
"linkedInstances.transfer.copyThere.turnOn": "Turn it on in {place}",
"linkedInstances.transfer.hint.turnsOffHere": "The workflow turns off here. It runs in {place} only when the copy there is on.",
"linkedInstances.transfer.hint.notLiveUntilSetUp": "This version can't go live in {place} until you set up its credentials there.",
"linkedInstances.transfer.hint.staysOnHere": "The workflow here stays on. Turn it off here after the copy is on in {place}.",
"linkedInstances.transfer.hint.nodeTypesUnchecked": "n8n checks the node types during the move. If {place} doesn't have one, this version can't go live there.",
"linkedInstances.transfer.hint.nodeTypesUncheckedStaysOnHere": "n8n checks the node types during the move. If {place} doesn't have one, this version can't go live there, and the workflow here stays on.",
"linkedInstances.transfer.submit": "Move to {place}",
"linkedInstances.transfer.moving": "Moving…",
"linkedInstances.transfer.moveFailed": "Couldn't move the workflow to {place}. Try again.",
"linkedInstances.transfer.result.title": "Moved to {place}",
"linkedInstances.transfer.result.live": "It's on in {place}.",
"linkedInstances.transfer.result.earlierLive": "An earlier version stays on in {place}.",
"linkedInstances.transfer.result.keptLive": "A version of it stays on in {place}. Open it there to see which version runs.",
"linkedInstances.transfer.result.notLive": "It's off in {place}.",
"linkedInstances.transfer.result.turnedOffHere": "It's off on this computer.",
"linkedInstances.transfer.result.open": "Open in {place}",
"linkedInstances.transfer.result.setUp.heading": "Needs setting up in {place}:",
"linkedInstances.transfer.result.setUp.link": "Set up in {place}",
"linkedInstances.transfer.result.setUp.linkLabel": "Set up in {place}: {credential} (opens in a new tab)",
"linkedInstances.transfer.result.nodeTypes.heading": "{place} doesn't have these node types:",
"linkedInstances.transfer.newTab": "(opens in a new tab)",
"settings.linkedInstances": "Linked instances",
"settings.linkedInstances.title": "Linked instances",
"settings.linkedInstances.description": "Link a cloud n8n so you can choose where chats and workflows run.",

View File

@ -54,6 +54,8 @@ import { useDependencies } from '@/app/composables/useDependencies';
import { useDependencyMenu } from '@/app/composables/useDependencyMenu';
import { useEmptyCanvasGroupsFlag } from '@/features/workflows/canvas/composables/useEmptyCanvasGroupsFlag';
import { removeEmptyCanvasGroupsFromWorkflowData } from '@/features/workflows/canvas/emptyGroup.utils';
import TransferWorkflowDialog from '@/features/linkedInstances/transfer/TransferWorkflowDialog.vue';
import { useMoveWorkflowMenu } from '@/features/linkedInstances/transfer/useMoveWorkflowMenu';
// Dependency submenu ids (`<type>:<id>`) share the menu with the fixed actions.
type WorkflowMenuItem = DropdownMenuItemProps<WORKFLOW_MENU_ACTIONS | string>;
@ -94,6 +96,7 @@ const { getDependencies, fetchDependencies, fetchDependencyCounts, hasDependenci
useDependencies();
const { buildDependencyMenuItems, resolveDependencyMenuId, openDependency } = useDependencyMenu();
const emptyCanvasGroupsEnabled = useEmptyCanvasGroupsFlag();
const { dialog: moveDialog, ...moveMenu } = useMoveWorkflowMenu(props, 'workflowMenu');
// Prefetch the lightweight counts so the menu knows whether to show the entry.
watch(
@ -292,6 +295,8 @@ const workflowMenuItems = computed<WorkflowMenuItem[]>(() => {
});
}
importExport.push(...moveMenu.menuItems.value);
if (hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } })) {
importExport.push({
id: WORKFLOW_MENU_ACTIONS.PUSH,
@ -395,11 +400,13 @@ function openDescriptionAndTagsModal(): void {
// Always refetch on open — cached entries may be stale (e.g. a credential
// deleted since the last fetch)
function onWorkflowMenuToggle(open: boolean): void {
moveMenu.onMenuToggle(open);
if (!open || !props.id || props.isNewWorkflow) return;
void fetchDependencies([props.id], 'workflow');
}
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS | string): Promise<void> {
if (moveMenu.select(action)) return;
const dependency = resolveDependencyMenuId(
getDependencies(props.id, 'workflow')?.dependencies ?? [],
action,
@ -617,6 +624,7 @@ defineExpose({
</span>
<!-- sub-menu-max-height: ~12 rows of 32px; a longer submenu (e.g. dependencies) scrolls -->
<N8nDropdownMenu
ref="workflowMenu"
:items="workflowMenuItems"
data-test-id="workflow-menu"
content-test-id="workflow-menu"
@ -636,6 +644,7 @@ defineExpose({
/>
</template>
</N8nDropdownMenu>
<TransferWorkflowDialog v-if="moveDialog" v-bind="moveDialog" />
</div>
</template>
<style lang="scss" module>

View File

@ -1,4 +1,8 @@
import type { LinkedInstanceSummary } from '@n8n/api-types';
import type {
LinkedInstancePushResult,
LinkedInstanceSummary,
LinkedInstanceTransferPreflight,
} from '@n8n/api-types';
export function linkedInstance(
overrides: Partial<LinkedInstanceSummary> = {},
@ -29,3 +33,36 @@ export function deferred<T>() {
});
return { promise, resolve, reject };
}
export function transferPreflight(
overrides: Partial<LinkedInstanceTransferPreflight> = {},
): LinkedInstanceTransferPreflight {
return {
workflowName: 'Daily report',
moves: { nodes: 4 },
nodeTypeCheck: 'unknown',
missingNodeTypes: [],
credentials: [],
targetProject: { id: 'project-1', name: 'Automations' },
subWorkflowCalls: [],
...overrides,
};
}
export function pushResult(
overrides: Partial<LinkedInstancePushResult> = {},
): LinkedInstancePushResult {
return {
remoteWorkflowId: 'remote-wf-1',
remoteUrl: 'https://acme.app.n8n.cloud/workflow/remote-wf-1',
targetProject: { id: 'project-1', name: 'Automations' },
created: true,
published: false,
publishFailed: false,
credentialsNeedingSetup: [],
missingNodeTypes: [],
localDeactivated: false,
warnings: [],
...overrides,
};
}

View File

@ -24,7 +24,8 @@ const linkedInstanceSummarySchema = z.object({
const linkedInstanceListSchema = z.array(linkedInstanceSummarySchema);
const instancePath = (id: string) => `${ENDPOINT}/${encodeURIComponent(id)}`;
/** The REST path of one link. The id goes into the path, so it is encoded. */
export const instancePath = (id: string) => `${ENDPOINT}/${encodeURIComponent(id)}`;
/** The links of the current user, oldest first. Rejects when the response has an unknown shape. */
export async function fetchLinkedInstances(

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { N8nCheckbox, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed } from 'vue';
import type { TransferHint, TransferOptions } from './transferDialogState';
/** The choices of the move dialog, and the lines that tell what the picked choices do. */
const props = defineProps<{
options: TransferOptions;
hints: TransferHint[];
/** The name of the link. */
place: string;
disabled: boolean;
}>();
const turnOffHere = defineModel<boolean>('turnOffHere', { required: true });
const turnOn = defineModel<boolean>('turnOn', { required: true });
const HINT_TEXT: Record<TransferHint, BaseTextKey> = {
turnsOffHere: 'linkedInstances.transfer.hint.turnsOffHere',
notLiveUntilSetUp: 'linkedInstances.transfer.hint.notLiveUntilSetUp',
staysOnHere: 'linkedInstances.transfer.hint.staysOnHere',
nodeTypesUnchecked: 'linkedInstances.transfer.hint.nodeTypesUnchecked',
nodeTypesUncheckedStaysOnHere: 'linkedInstances.transfer.hint.nodeTypesUncheckedStaysOnHere',
};
const i18n = useI18n();
const interpolate = computed(() => ({ place: props.place }));
</script>
<template>
<div :class="$style.choices">
<section v-if="options.showTurnOffHere" :class="$style.choice">
<N8nText tag="h3" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.copyHere.heading') }}
</N8nText>
<N8nCheckbox
v-model="turnOffHere"
:label="i18n.baseText('linkedInstances.transfer.copyHere.turnOff')"
:disabled="disabled"
data-test-id="transfer-turn-off-here"
/>
</section>
<section v-if="options.showTurnOn" :class="$style.choice">
<N8nText tag="h3" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.copyThere.heading', { interpolate }) }}
</N8nText>
<N8nCheckbox
v-model="turnOn"
:label="i18n.baseText('linkedInstances.transfer.copyThere.turnOn', { interpolate })"
:disabled="disabled"
data-test-id="transfer-turn-on"
/>
</section>
<!-- The region is in the page before a choice changes, so screen readers read each new hint. -->
<div aria-live="polite" :class="$style.hints" data-test-id="transfer-hints">
<N8nText
v-for="hint in hints"
:key="hint"
tag="p"
size="small"
color="text-base"
:data-test-id="`transfer-hint-${hint}`"
>
{{ i18n.baseText(HINT_TEXT[hint], { interpolate }) }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.choices {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.choice {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.hints {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
// An empty live region stays in the page for screen readers, but out of the layout.
&:not(:has(> *)) {
position: absolute;
}
}
</style>

View File

@ -0,0 +1,215 @@
<script setup lang="ts">
import { N8nNotice, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed } from 'vue';
import type {
TransferDialogState,
TransferSetUpItem,
TransferWarning,
} from './transferDialogState';
/** The sections of the move dialog: what moves, what needs setting up and what blocks the move. */
const props = defineProps<{
state: TransferDialogState;
/** The name of the link. */
place: string;
}>();
const SET_UP_STATUS_TEXT: Record<TransferSetUpItem['status'], BaseTextKey> = {
'needs-set-up': 'linkedInstances.transfer.setUp.empty',
unknown: 'linkedInstances.transfer.setUp.unknown',
};
const WARNING_TEXT: Record<TransferWarning, BaseTextKey> = {
credentialsUnchecked: 'linkedInstances.transfer.warning.credentialsUnchecked',
};
const i18n = useI18n();
const interpolate = computed(() => ({ place: props.place }));
const nodes = computed(() =>
i18n.baseText('linkedInstances.transfer.moves.nodes', {
adjustToNumber: props.state.nodeCount,
interpolate: { count: String(props.state.nodeCount) },
}),
);
const project = computed(() => {
const name = props.state.targetProjectName;
return name === null
? i18n.baseText('linkedInstances.transfer.moves.personalProject')
: i18n.baseText('linkedInstances.transfer.moves.project', { interpolate: { project: name } });
});
const matchedCredentials = computed(() => {
const names = props.state.matchedCredentials;
if (names.length === 0) return undefined;
const list = new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' });
return i18n.baseText('linkedInstances.transfer.moves.matchedCredentials', {
adjustToNumber: names.length,
interpolate: { place: props.place, names: list.format(names) },
});
});
const cannotMove = computed(
() => props.state.missingNodeTypes.length > 0 || props.state.subWorkflowCalls.length > 0,
);
function setUpStatus({ status }: TransferSetUpItem): string {
return i18n.baseText(SET_UP_STATUS_TEXT[status]);
}
function setUpRow(credential: TransferSetUpItem): string {
return i18n.baseText('linkedInstances.transfer.setUp.row', {
interpolate: { name: credential.name, status: setUpStatus(credential) },
});
}
function subWorkflowLabel({ id, name }: { id: string; name: string | null }): string {
return (
name ??
i18n.baseText('linkedInstances.transfer.cannotMove.hiddenWorkflow', { interpolate: { id } })
);
}
</script>
<template>
<div :class="$style.summary">
<!-- "Can't move" comes first: it is why Move is disabled, so it must not scroll out of view. -->
<section v-if="cannotMove" :class="$style.section" data-test-id="transfer-cannot-move">
<N8nText tag="h3" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.cannotMove.heading') }}
</N8nText>
<!-- role="note": the status line announces the result, so the notices do not interrupt it. -->
<template v-if="state.subWorkflowCalls.length > 0">
<N8nNotice theme="warning" role="note" :class="$style.notice">
{{ i18n.baseText('linkedInstances.transfer.cannotMove.subWorkflows') }}
</N8nNotice>
<ul :class="$style.list">
<li v-for="call in state.subWorkflowCalls" :key="call.id">
<N8nText size="small">{{ subWorkflowLabel(call) }}</N8nText>
</li>
</ul>
</template>
<template v-if="state.missingNodeTypes.length > 0">
<N8nNotice theme="warning" role="note" :class="$style.notice">
{{ i18n.baseText('linkedInstances.transfer.cannotMove.nodeTypes', { interpolate }) }}
</N8nNotice>
<ul :class="$style.list">
<li v-for="nodeType in state.missingNodeTypes" :key="nodeType">
<code :class="$style.code">{{ nodeType }}</code>
</li>
</ul>
</template>
</section>
<section :class="$style.section">
<N8nText tag="h3" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.moves.heading') }}
</N8nText>
<!-- Safari drops the list role of a list without bullets, so the role is explicit. -->
<ul :class="$style.facts" role="list" data-test-id="transfer-moves">
<li>
<N8nText size="small">{{ nodes }}</N8nText>
</li>
<li>
<N8nText size="small">{{ project }}</N8nText>
</li>
<li v-if="matchedCredentials">
<N8nText size="small">{{ matchedCredentials }}</N8nText>
</li>
</ul>
</section>
<section v-if="state.needsSetUp.length > 0" :class="$style.section">
<N8nText tag="h3" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.setUp.heading') }}
</N8nText>
<ul :class="$style.facts" role="list" data-test-id="transfer-needs-set-up">
<li v-for="(credential, index) in state.needsSetUp" :key="index" :class="$style.setUpItem">
<!-- Screen readers read the row as one translated line. The two columns are visual only. -->
<span :class="$style.visuallyHidden">{{ setUpRow(credential) }}</span>
<span :class="$style.row" aria-hidden="true">
<N8nText size="small">{{ credential.name }}</N8nText>
<N8nText size="small" color="text-base">{{ setUpStatus(credential) }}</N8nText>
</span>
</li>
</ul>
</section>
<ul
v-if="state.warnings.length > 0"
:class="[$style.facts, $style.warnings]"
role="list"
data-test-id="transfer-warnings"
>
<li v-for="warning in state.warnings" :key="warning">
<N8nText size="small" color="text-base">
{{ i18n.baseText(WARNING_TEXT[warning], { interpolate }) }}
</N8nText>
</li>
</ul>
</div>
</template>
<style lang="scss" module>
@use './visually-hidden' as a11y;
.summary {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.facts {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
margin: 0;
padding: 0;
list-style: none;
}
// Keeps the hidden row text next to its row, inside the scrolling details.
.setUpItem {
position: relative;
}
.row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.notice {
--notice--margin: 0;
}
.list {
margin: 0;
padding-left: var(--spacing--md);
list-style: disc;
}
.code {
font-family: var(--font-family--monospace);
font-size: var(--font-size--2xs);
}
.warnings {
padding-top: var(--spacing--3xs);
border-top: var(--border);
}
.visuallyHidden {
@include a11y.visually-hidden;
}
</style>

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import type { LinkedInstancePushResult } from '@n8n/api-types';
import { N8nExternalLink, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed } from 'vue';
import { transferResultView, type RemoteCopyState } from './transferResult';
/**
* The body of the toast after a move. Names, node types and warnings can repeat text of the linked
* instance, so they render as text only. Every link opens in a new tab without an opener.
*/
const props = defineProps<{
result: LinkedInstancePushResult;
/** The name of the link. */
place: string;
/** The address of the linked instance, for the links to its credentials. */
baseUrl: string;
/** The move asked to turn on the copy there. */
publishAsked: boolean;
}>();
const REMOTE_STATE_TEXT: Record<RemoteCopyState, BaseTextKey> = {
live: 'linkedInstances.transfer.result.live',
earlierLive: 'linkedInstances.transfer.result.earlierLive',
keptLive: 'linkedInstances.transfer.result.keptLive',
notLive: 'linkedInstances.transfer.result.notLive',
};
const i18n = useI18n();
const view = computed(() => transferResultView(props.result, props.baseUrl, props.publishAsked));
const interpolate = computed(() => ({ place: props.place }));
const newTab = computed(() => i18n.baseText('linkedInstances.transfer.newTab'));
// Each link names its credential, so a list of links reads well without the item text.
function setUpLabel(credential: string): string {
return i18n.baseText('linkedInstances.transfer.result.setUp.linkLabel', {
interpolate: { place: props.place, credential },
});
}
</script>
<template>
<div :class="$style.message" data-test-id="transfer-result-message">
<N8nText tag="p" size="small">
{{ i18n.baseText(REMOTE_STATE_TEXT[view.remoteState], { interpolate }) }}
<template v-if="view.turnedOffHere">
{{ i18n.baseText('linkedInstances.transfer.result.turnedOffHere') }}
</template>
</N8nText>
<ul
v-if="view.warnings.length > 0"
:class="$style.list"
data-test-id="transfer-result-warnings"
>
<li v-for="(warning, index) in view.warnings" :key="index">
<N8nText size="small">{{ warning }}</N8nText>
</li>
</ul>
<template v-if="view.needsSetUp.length > 0">
<N8nText tag="p" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.result.setUp.heading', { interpolate }) }}
</N8nText>
<ul :class="$style.list" data-test-id="transfer-result-set-up">
<li v-for="credential in view.needsSetUp" :key="credential.id" :class="$style.setUpItem">
<N8nText size="small">{{ credential.name }}</N8nText>
<N8nExternalLink
v-if="credential.url"
:href="credential.url"
size="small"
:aria-label="setUpLabel(credential.name)"
>
{{ i18n.baseText('linkedInstances.transfer.result.setUp.link', { interpolate }) }}
</N8nExternalLink>
</li>
</ul>
</template>
<template v-if="view.missingNodeTypes.length > 0">
<N8nText tag="p" size="small" bold>
{{ i18n.baseText('linkedInstances.transfer.result.nodeTypes.heading', { interpolate }) }}
</N8nText>
<ul :class="$style.list" data-test-id="transfer-result-node-types">
<li v-for="nodeType in view.missingNodeTypes" :key="nodeType">
<code :class="$style.code">{{ nodeType }}</code>
</li>
</ul>
</template>
<N8nExternalLink
v-if="view.openUrl"
:href="view.openUrl"
:class="$style.open"
data-test-id="transfer-result-open"
>
{{ i18n.baseText('linkedInstances.transfer.result.open', { interpolate }) }}
<span :class="$style.visuallyHidden">{{ newTab }}</span>
</N8nExternalLink>
</div>
</template>
<style lang="scss" module>
@use './visually-hidden' as a11y;
.message {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--3xs);
padding-top: var(--spacing--3xs);
}
.list {
margin: 0;
padding-left: var(--spacing--sm);
list-style: disc;
}
.setUpItem {
display: list-item;
> * {
vertical-align: middle;
}
}
.code {
font-family: var(--font-family--monospace);
font-size: var(--font-size--2xs);
}
.open {
margin-left: calc(-1 * var(--spacing--2xs));
}
.visuallyHidden {
@include a11y.visually-hidden;
}
</style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { transferStatus, type TransferCheck, type TransferMoveStatus } from './transferDialogState';
/**
* One short line that tells screen reader users what the move dialog does now. The details stay
* in the dialog, so a change does not read out the whole summary.
*/
const props = defineProps<{
check: TransferCheck;
move: TransferMoveStatus;
/** The name of the link. */
place: string;
}>();
const i18n = useI18n();
function readyText(nodeCount: number, needsSetUp: number): string {
const checked = i18n.baseText('linkedInstances.transfer.status.checked', {
adjustToNumber: nodeCount,
interpolate: { count: String(nodeCount), place: props.place },
});
if (needsSetUp === 0) return checked;
const setUp = i18n.baseText('linkedInstances.transfer.status.needsSetUp', {
adjustToNumber: needsSetUp,
interpolate: { count: String(needsSetUp) },
});
return i18n.baseText('linkedInstances.transfer.status.checkedNeedsSetUp', {
interpolate: { checked, setUp },
});
}
const text = computed(() => {
const status = transferStatus(props.check, props.move);
const interpolate = { place: props.place };
switch (status.kind) {
case 'checking':
return i18n.baseText('linkedInstances.transfer.checking');
case 'cannotMove':
return i18n.baseText('linkedInstances.transfer.status.cannotMove', { interpolate });
case 'ready':
return readyText(status.nodeCount, status.needsSetUp);
case 'moving':
return i18n.baseText('linkedInstances.transfer.status.moving', { interpolate });
}
// 'silent': each error has its own alert, so the line says nothing more.
return '';
});
</script>
<template>
<p role="status" :class="$style.status" data-test-id="transfer-status">{{ text }}</p>
</template>
<style lang="scss" module>
@use './visually-hidden' as a11y;
.status {
@include a11y.visually-hidden;
margin: 0;
}
</style>

View File

@ -0,0 +1,426 @@
<script setup lang="ts">
import type { LinkedInstancePushResult, LinkedInstanceSummary } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nDialog,
N8nDialogFooter,
N8nNotice,
N8nSpinner,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useResizeObserver } from '@vueuse/core';
import { computed, h, nextTick, reactive, ref, useTemplateRef, watch } from 'vue';
import StableButtonLabel from '../components/StableButtonLabel.vue';
import { syncLocalTurnOff } from './syncLocalTurnOff';
import TransferChoices from './TransferChoices.vue';
import TransferPreflightSummary from './TransferPreflightSummary.vue';
import TransferResultMessage from './TransferResultMessage.vue';
import TransferStatusLine from './TransferStatusLine.vue';
import {
transferDialogState,
transferHints,
transferOptions,
transferRequest,
type TransferCheck,
type TransferMoveStatus,
type TransferWorkflow,
} from './transferDialogState';
import { transferResultView } from './transferResult';
import { useTransferWorkflow } from './useTransferWorkflow';
const props = defineProps<{
open: boolean;
workflow: TransferWorkflow;
instance: LinkedInstanceSummary;
/** The user asked for a live automation, so the dialog offers to turn the copy on. */
offerTurnOn?: boolean;
/** Opened from an Assistant chat: the dialog says that the chat stays here. */
fromAssistant?: boolean;
}>();
const emit = defineEmits<{
'update:open': [open: boolean];
moved: [result: LinkedInstancePushResult];
/** The dialog has left the page, so the opener can move focus. */
closed: [];
}>();
/** A finished move and whether it asked to turn on the copy there. */
interface MovedResult {
result: LinkedInstancePushResult;
publishAsked: boolean;
}
const i18n = useI18n();
const toast = useToast();
const body = useTemplateRef<HTMLElement>('body');
const details = useTemplateRef<HTMLElement>('details');
const detailsContent = useTemplateRef<HTMLElement>('detailsContent');
const place = computed(() => props.instance.name);
const interpolate = computed(() => ({ place: place.value }));
const { preflight, isMoving, moveError, retry, move } = useTransferWorkflow(
() => props.open,
() => ({ linkId: props.instance.id, workflowId: props.workflow.id }),
{
checkFailed: () =>
i18n.baseText('linkedInstances.transfer.checkFailed', { interpolate: interpolate.value }),
moveFailed: () =>
i18n.baseText('linkedInstances.transfer.moveFailed', { interpolate: interpolate.value }),
},
);
const choice = reactive({ turnOffHere: false, turnOn: false });
// Shown as a toast once the dialog has left, so that screen readers announce it.
let moved: MovedResult | undefined;
const state = computed(() =>
preflight.value.status === 'ready' ? transferDialogState(preflight.value.preflight) : undefined,
);
const check = computed<TransferCheck>(
() => state.value ?? (preflight.value.status === 'failed' ? 'failed' : 'checking'),
);
const moveStatus = computed<TransferMoveStatus>(() => {
if (isMoving.value) return 'moving';
return moveError.value ? 'failed' : 'idle';
});
const options = computed(() =>
transferOptions({
liveHere: props.workflow.liveHere,
canUnpublish: props.workflow.canUnpublish,
offerTurnOn: props.offerTurnOn === true,
}),
);
const showChoices = computed(
() =>
state.value?.canMove === true && (options.value.showTurnOffHere || options.value.showTurnOn),
);
const hints = computed(() =>
state.value ? transferHints(state.value, options.value, choice) : [],
);
const canSubmit = computed(() => state.value?.canMove === true && !isMoving.value);
// A long list of credentials scrolls. Only then the details take a stop in the Tab order.
const detailsScroll = ref(false);
useResizeObserver([details, detailsContent], () => {
const element = details.value;
detailsScroll.value = element !== null && element.scrollHeight > element.clientHeight;
});
const title = computed(() =>
i18n.baseText('linkedInstances.transfer.title', {
interpolate: { name: props.workflow.name, place: place.value },
}),
);
// The editor shows no description: only an Assistant chat needs to say that it stays here.
const description = computed(() =>
props.fromAssistant
? i18n.baseText('linkedInstances.transfer.description.fromAssistant')
: undefined,
);
const submitLabel = computed(() =>
isMoving.value
? i18n.baseText('linkedInstances.transfer.moving')
: i18n.baseText('linkedInstances.transfer.submit', { interpolate: interpolate.value }),
);
const submitLabels = computed(() => [
i18n.baseText('linkedInstances.transfer.submit', { interpolate: interpolate.value }),
i18n.baseText('linkedInstances.transfer.moving'),
]);
watch(
() => props.open,
(open) => {
if (!open) return;
choice.turnOffHere = false;
choice.turnOn = false;
moved = undefined;
},
{ immediate: true },
);
function actionButton(action: 'cancel' | 'submit' | 'retry'): HTMLElement | null {
return body.value?.querySelector<HTMLElement>(`[data-transfer-action="${action}"]`) ?? null;
}
/**
* A button that becomes disabled or goes away drops focus. Then focus goes to the target. Focus on
* `holder`, which only held it for a moment, also goes to the target.
*/
async function focusIfLost(target: () => HTMLElement | null, holder?: HTMLElement | null) {
await nextTick();
const active = document.activeElement;
if (active === holder || !body.value?.contains(active)) target()?.focus();
}
async function onRetry() {
await retry();
await focusIfLost(() => actionButton(preflight.value.status === 'failed' ? 'retry' : 'cancel'));
}
async function submit() {
if (!canSubmit.value) return;
const request = transferRequest(props.workflow.id, options.value, choice);
const moving = move(request);
// Every control is disabled while the move runs. The details keep focus in the dialog.
await nextTick();
details.value?.focus();
const result = await moving;
if (!result) {
await focusIfLost(() => actionButton('submit'), details.value);
return;
}
if (result.localDeactivated) void syncLocalTurnOff(props.workflow.id);
moved = { result, publishAsked: request.publish === true };
emit('moved', result);
emit('update:open', false);
}
function showResultToast({ result, publishAsked }: MovedResult) {
toast.showToast({
title: i18n.baseText('linkedInstances.transfer.result.title', {
interpolate: interpolate.value,
}),
message: h(TransferResultMessage, {
result,
place: place.value,
baseUrl: props.instance.baseUrl,
publishAsked,
}),
type: transferResultView(result, props.instance.baseUrl, publishAsked).tone,
// The toast holds links, so it stays until the user closes it.
duration: 0,
});
}
// The move cannot be stopped. The dialog stays open until it ends, so the user sees the result.
function onOpenChange(open: boolean) {
if (!open && isMoving.value) return;
emit('update:open', open);
}
// The safe choice has focus first. "Move" needs the check, so it is disabled at the start.
function onOpenAutoFocus(event: Event) {
event.preventDefault();
actionButton('cancel')?.focus();
}
// The page stays hidden from screen readers until the dialog has left it, which ends after this
// event. So the toast and the focus move wait for one more task.
function onCloseAutoFocus(event: Event) {
event.preventDefault();
const result = moved;
moved = undefined;
setTimeout(() => {
if (result) showResultToast(result);
emit('closed');
}, 0);
}
</script>
<template>
<N8nDialog
:open="open"
:header="title"
:description="description"
size="medium"
:container-class="$style.dialog"
:show-close-button="!isMoving"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@update:open="onOpenChange"
>
<div ref="body" :class="$style.body" data-test-id="transfer-workflow-dialog">
<TransferStatusLine :check="check" :move="moveStatus" :place="place" />
<div
ref="details"
:class="$style.details"
role="region"
:aria-label="i18n.baseText('linkedInstances.transfer.details')"
:tabindex="detailsScroll ? 0 : -1"
data-test-id="transfer-details"
>
<div ref="detailsContent" :class="$style.detailsContent">
<!-- The status line reads this text, so screen readers skip the copy here. -->
<p
v-if="preflight.status === 'checking'"
:class="$style.checking"
aria-hidden="true"
data-test-id="transfer-checking"
>
<span :class="$style.spinner"><N8nSpinner size="small" /></span>
<N8nText size="small">{{ i18n.baseText('linkedInstances.transfer.checking') }}</N8nText>
</p>
<div v-else-if="preflight.status === 'failed'" :class="$style.failed">
<N8nNotice theme="warning" :class="$style.notice" data-test-id="transfer-check-error">
{{ preflight.message }}
</N8nNotice>
<N8nButton
variant="outline"
size="small"
:label="i18n.baseText('linkedInstances.transfer.retry')"
data-transfer-action="retry"
data-test-id="transfer-retry"
@click="onRetry"
/>
</div>
<TransferPreflightSummary v-else-if="state" :state="state" :place="place" />
<TransferChoices
v-if="showChoices"
v-model:turn-off-here="choice.turnOffHere"
v-model:turn-on="choice.turnOn"
:options="options"
:hints="hints"
:place="place"
:disabled="isMoving"
/>
</div>
</div>
<!-- Outside the scrolling details, so the error is always in view next to the Move button. -->
<N8nNotice
v-if="moveError"
theme="danger"
:class="$style.moveError"
data-test-id="transfer-move-error"
>
{{ moveError }}
</N8nNotice>
<N8nDialogFooter :class="$style.footer">
<N8nButton
variant="outline"
:disabled="isMoving"
:label="i18n.baseText('generic.cancel')"
data-transfer-action="cancel"
data-test-id="transfer-cancel"
@click="onOpenChange(false)"
/>
<!-- The status line reads "Moving", so the label change is not read a second time. -->
<N8nButton
variant="solid"
:class="$style.submit"
:disabled="!canSubmit"
aria-live="off"
data-transfer-action="submit"
data-test-id="transfer-submit"
@click="submit"
>
<StableButtonLabel :label="submitLabel" :labels="submitLabels" />
</N8nButton>
</N8nDialogFooter>
</div>
</N8nDialog>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/focus';
// The dialog starts at a fixed distance from the top, so a change of its height moves only what is
// below the change. `translate` adds to the centring `transform` of the design system and cancels
// its vertical part, so the open animation stays the same.
.dialog[role='dialog'] {
--transfer-dialog--top: min(12dvh, var(--spacing--4xl));
top: var(--transfer-dialog--top);
translate: 0 50%;
display: flex;
flex-direction: column;
max-height: calc(100dvh - var(--transfer-dialog--top) - var(--spacing--lg));
// Names can be long and have no spaces. They wrap in place of running out of the dialog.
overflow-wrap: anywhere;
// A title that wraps stays clear of the close button.
header {
padding-inline-end: var(--spacing--lg);
}
}
.body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
padding-block: var(--spacing--xs) 0;
}
// Only the details scroll. The title and the buttons stay in view.
.details {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
// Room for the focus rings of the controls, which the scroll box clips.
margin: calc(-1 * var(--spacing--4xs));
padding: var(--spacing--4xs);
border-radius: var(--radius);
@include focus.focus-visible-ring;
}
.detailsContent {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.checking {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
margin: 0;
}
.spinner {
display: inline-flex;
// The blocks do not move with reduced motion. The text alone tells that the check runs.
@media (prefers-reduced-motion: reduce) {
display: none;
}
}
.failed {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
}
.notice {
--notice--margin: 0;
}
.moveError {
--notice--margin: var(--spacing--sm) 0 0;
flex: none;
}
// With a long place name, Move goes below Cancel, and its label ends in an ellipsis. The title
// shows the full name.
.footer {
flex-wrap: wrap;
}
.submit {
max-width: 100%;
// The design-system button and its label do not shrink below the width of the text.
&,
* {
min-width: 0;
}
// The texts of the label. A line height above 1 keeps the descenders inside the clip.
span span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
line-height: var(--line-height--md);
}
}
</style>

View File

@ -0,0 +1,102 @@
import type { LinkedInstancePushResult } from '@n8n/api-types';
import { screen, within } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { pushResult } from '../../__tests__/linkedInstances.fixtures';
import TransferResultMessage from '../TransferResultMessage.vue';
const renderMessage = createComponentRenderer(TransferResultMessage);
function setup(
result: LinkedInstancePushResult,
{ baseUrl = 'https://acme.app.n8n.cloud', publishAsked = false } = {},
) {
return renderMessage({ props: { result, place: 'Acme Cloud', baseUrl, publishAsked } });
}
describe('TransferResultMessage', () => {
it.each<[string, Partial<LinkedInstancePushResult>, string]>([
['the move put the new version live', { published: true }, "It's on in Acme Cloud."],
[
'the publish failed and an earlier version stays live',
{ published: true, publishFailed: true },
'An earlier version stays on in Acme Cloud.',
],
['nothing is live', { published: false, publishFailed: true }, "It's off in Acme Cloud."],
])('says so when %s', (_label, overrides, text) => {
setup(pushResult(overrides), { publishAsked: true });
expect(screen.getByText(text)).toBeVisible();
});
it('does not say that the moved version is on when the move did not publish it', () => {
const warning =
'The new version is not live, because the source workflow does not publish this version. Publish the workflow to make it live. An earlier version stays live.';
setup(pushResult({ created: false, published: true, warnings: [warning] }));
expect(
screen.getByText(
'A version of it stays on in Acme Cloud. Open it there to see which version runs.',
),
).toBeVisible();
expect(screen.queryByText("It's on in Acme Cloud.")).not.toBeInTheDocument();
expect(screen.getByText(warning)).toBeVisible();
});
it("says that the copy is off when the move did not publish it and it wasn't on", () => {
setup(pushResult({ published: false }));
expect(screen.getByText("It's off in Acme Cloud.")).toBeVisible();
});
it('says that the workflow here is off only when the move turned it off', () => {
const { unmount } = setup(pushResult({ localDeactivated: false }));
expect(screen.queryByText(/It's off on this computer/)).not.toBeInTheDocument();
unmount();
setup(pushResult({ localDeactivated: true }));
expect(screen.getByText(/It's off on this computer/)).toBeVisible();
});
it('lists the node types that the linked instance does not have', () => {
setup(pushResult({ missingNodeTypes: ['acme.thing@2', 'acme.other@1'] }));
expect(screen.getByText("Acme Cloud doesn't have these node types:")).toBeVisible();
const types = within(screen.getByTestId('transfer-result-node-types')).getAllByRole('listitem');
expect(types.map((item) => item.textContent)).toEqual(['acme.thing@2', 'acme.other@1']);
});
it('shows the warnings of the server as plain text', () => {
const warning = 'Ops <b>team</b> refused it.';
setup(pushResult({ warnings: [warning] }));
expect(screen.getByText(warning)).toBeVisible();
expect(screen.getByTestId('transfer-result-warnings').querySelector('b')).toBeNull();
});
it('names a credential without a link when the address of the instance is not http(s)', () => {
setup(
pushResult({
credentialsNeedingSetup: [{ id: 'cred-1', name: 'Gmail', type: 'gmailOAuth2' }],
}),
{ baseUrl: 'ftp://acme.example.test' },
);
expect(screen.getByText('Gmail')).toBeVisible();
expect(screen.queryByRole('link', { name: /Set up in Acme Cloud/ })).not.toBeInTheDocument();
});
it('offers no link to a copy whose address is not http(s)', () => {
setup(pushResult({ remoteUrl: 'ftp://acme.example.test/workflow/1' }));
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('tells screen reader users that the link opens a new tab', () => {
setup(pushResult());
expect(
screen.getByRole('link', { name: 'Open in Acme Cloud (opens in a new tab)' }),
).toHaveAttribute('target', '_blank');
});
});

View File

@ -0,0 +1,677 @@
import type { LinkedInstancePushResult, LinkedInstanceTransferPreflight } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import { createTestingPinia } from '@pinia/testing';
import { render as renderVNode, screen, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { VNode } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import {
deferred,
linkedInstance,
pushResult,
transferPreflight,
} from '../../__tests__/linkedInstances.fixtures';
import type * as Api from '../transfer.api';
import TransferWorkflowDialog from '../TransferWorkflowDialog.vue';
import type { TransferWorkflow } from '../transferDialogState';
const api = vi.hoisted(() => ({
fetchTransferPreflight: vi.fn<typeof Api.fetchTransferPreflight>(),
moveWorkflow: vi.fn<typeof Api.moveWorkflow>(),
}));
vi.mock('../transfer.api', () => api);
const syncLocalTurnOff = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('../syncLocalTurnOff', () => ({ syncLocalTurnOff }));
const toast = vi.hoisted(() => ({
showToast: vi.fn(),
showMessage: vi.fn(),
showError: vi.fn(),
}));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => toast }));
const UNREACHABLE = "Can't reach Acme Cloud. Check that it's running, then try again.";
const workflow = (overrides: Partial<TransferWorkflow> = {}): TransferWorkflow => ({
id: 'wf-1',
name: 'Daily report',
liveHere: false,
canUnpublish: false,
...overrides,
});
const renderDialog = createComponentRenderer(TransferWorkflowDialog);
type Props = {
workflow?: TransferWorkflow;
offerTurnOn?: boolean;
fromAssistant?: boolean;
};
async function setup(props: Props = {}, preflight = transferPreflight()) {
api.fetchTransferPreflight.mockResolvedValue(preflight);
const result = renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance(), ...props },
});
await screen.findByTestId('transfer-workflow-dialog');
return result;
}
async function setupReady(props: Props = {}, preflight = transferPreflight()) {
const result = await setup(props, preflight);
await screen.findByTestId('transfer-moves');
return result;
}
const submitButton = () => screen.getByTestId('transfer-submit');
const cancelButton = () => screen.getByTestId('transfer-cancel');
const details = () => screen.getByRole('region', { name: 'Move details' });
// By test id: the spinner of the design system has a role="status" of its own.
const statusLine = () => screen.getByTestId('transfer-status');
/** The text that a screen reader reads: the text outside aria-hidden parts. */
function spokenText(element: Element): string {
const parts: string[] = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
acceptNode: (node) =>
node.parentElement?.closest('[aria-hidden="true"]')
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
});
while (walker.nextNode()) parts.push(walker.currentNode.textContent ?? '');
return parts.join(' ').replace(/\s+/g, ' ').trim();
}
/** The toast is a VNode. Rendering it shows what the user sees. */
function renderToastMessage() {
const [[config]] = toast.showToast.mock.calls as Array<[{ message: VNode }]>;
return renderVNode({ render: () => config.message });
}
describe('TransferWorkflowDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('loading', () => {
it('checks what moves when it opens, with Move disabled and focus on Cancel', async () => {
const preflight = deferred<LinkedInstanceTransferPreflight>();
api.fetchTransferPreflight.mockReturnValue(preflight.promise);
renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance() },
});
expect(await screen.findByTestId('transfer-checking')).toHaveTextContent(
'Checking what moves…',
);
expect(statusLine()).toHaveAttribute('role', 'status');
expect(statusLine()).toHaveTextContent('Checking what moves…');
expect(api.fetchTransferPreflight).toHaveBeenCalledWith(expect.anything(), 'link-1', {
workflowId: 'wf-1',
});
expect(submitButton()).toBeDisabled();
await waitFor(() => expect(cancelButton()).toHaveFocus());
preflight.resolve(transferPreflight());
expect(await screen.findByTestId('transfer-moves')).toBeVisible();
expect(screen.queryByTestId('transfer-checking')).not.toBeInTheDocument();
expect(statusLine()).toHaveTextContent('Check done. 4 nodes move to Acme Cloud.');
expect(submitButton()).toBeEnabled();
});
it('names the workflow and the place in the title, with no description in the editor', async () => {
await setup();
expect(
screen.getByRole('dialog', { name: 'Move "Daily report" to Acme Cloud?' }),
).not.toHaveAccessibleDescription();
expect(
screen.queryByText('This chat stays here. The workflow moves.'),
).not.toBeInTheDocument();
});
it('says that the chat stays here when an Assistant chat opened it', async () => {
await setup({ fromAssistant: true });
expect(screen.getByRole('dialog')).toHaveAccessibleDescription(
'This chat stays here. The workflow moves.',
);
});
});
describe('sections', () => {
it('shows what moves, where it goes and the credentials that are there already', async () => {
await setupReady(
{},
transferPreflight({
moves: { nodes: 5 },
targetProject: { id: 'p-1', name: 'Automations' },
credentials: [
{ name: 'Slack', type: 'slackApi', status: 'matched' },
{ name: 'Jira', type: 'jiraApi', status: 'matched' },
],
}),
);
const moves = within(screen.getByTestId('transfer-moves'));
expect(moves.getByText('5 nodes')).toBeVisible();
expect(moves.getByText('Goes to the project Automations')).toBeVisible();
expect(
moves.getByText('Uses the credentials with the same names in Acme Cloud: Slack and Jira'),
).toBeVisible();
expect(screen.queryByText('Needs setting up')).not.toBeInTheDocument();
expect(screen.queryByText("Can't move")).not.toBeInTheDocument();
});
it('names the personal project and counts one node', async () => {
await setupReady({}, transferPreflight({ moves: { nodes: 1 }, targetProject: null }));
expect(screen.getByText('1 node')).toBeVisible();
expect(screen.getByText('Goes to your personal project')).toBeVisible();
expect(statusLine()).toHaveTextContent('Check done. 1 node moves to Acme Cloud.');
});
it('uses the singular form for one credential that is there already', async () => {
await setupReady(
{},
transferPreflight({
credentials: [{ name: 'Slack', type: 'slackApi', status: 'matched' }],
}),
);
expect(
within(screen.getByTestId('transfer-moves')).getByText(
'Uses the credential with the same name in Acme Cloud: Slack',
),
).toBeVisible();
});
it('lists the credentials that need setting up with their status', async () => {
await setupReady(
{},
transferPreflight({
credentials: [
{ name: 'Gmail account', type: 'gmailOAuth2', status: 'needs-set-up' },
{ name: 'Notion', type: 'notionApi', status: 'unknown' },
],
}),
);
expect(screen.getByRole('heading', { name: 'Needs setting up' })).toBeVisible();
const list = screen.getByTestId('transfer-needs-set-up');
// Safari reads a list without bullets as a list only with an explicit role.
expect(list).toHaveAttribute('role', 'list');
const rows = within(list).getAllByRole('listitem');
// Two columns on screen, one translated line for screen readers.
expect(rows.map(spokenText)).toEqual(['Gmail account: Arrives empty', 'Notion: Not checked']);
expect(within(rows[0]).getByText('Gmail account')).toBeVisible();
expect(within(rows[0]).getByText('Arrives empty')).toBeVisible();
expect(
screen.getByText(
"n8n couldn't check the credentials in Acme Cloud. Some may need setting up after the move.",
),
).toBeVisible();
// One short line for screen readers, in place of the whole summary.
expect(statusLine()).toHaveTextContent(
'Check done. 4 nodes move to Acme Cloud. 2 credentials need setting up.',
);
});
it('mentions the node type check only when the user turns the copy on', async () => {
const text =
"n8n checks the node types during the move. If Acme Cloud doesn't have one, this version can't go live there.";
await setupReady({ offerTurnOn: true }, transferPreflight({ nodeTypeCheck: 'unknown' }));
expect(screen.queryByText(text)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('checkbox', { name: 'Turn it on in Acme Cloud' }));
expect(screen.getByText(text)).toBeVisible();
});
it('names the called workflows under "Can\'t move" and disables Move', async () => {
await setupReady(
{ workflow: workflow({ liveHere: true, canUnpublish: true }), offerTurnOn: true },
transferPreflight({
subWorkflowCalls: [
{ id: 'wf-2', name: 'Enrich lead' },
{ id: 'wf-3', name: null },
],
}),
);
expect(screen.getByRole('heading', { name: "Can't move" })).toBeVisible();
const blocked = within(screen.getByTestId('transfer-cannot-move'));
expect(blocked.getByText('Enrich lead')).toBeVisible();
expect(blocked.getByText("A workflow you can't open (ID wf-3)")).toBeVisible();
expect(submitButton()).toBeDisabled();
expect(statusLine()).toHaveTextContent("Check done. The workflow can't move to Acme Cloud.");
// Choices do not matter while the workflow cannot move.
expect(screen.queryByTestId('transfer-turn-off-here')).not.toBeInTheDocument();
expect(screen.queryByTestId('transfer-turn-on')).not.toBeInTheDocument();
});
it('shows why Move is off first, and lets only the status line announce it', async () => {
await setupReady(
{},
transferPreflight({
nodeTypeCheck: 'checked',
missingNodeTypes: ['acme.thing@2'],
subWorkflowCalls: [{ id: 'wf-2', name: 'Enrich lead' }],
credentials: [{ name: 'Gmail', type: 'gmailOAuth2', status: 'needs-set-up' }],
}),
);
const blocked = screen.getByTestId('transfer-cannot-move');
// A long "Needs setting up" list cannot push the reason out of view.
const [first] = within(details()).getAllByRole('heading');
expect(first).toHaveTextContent("Can't move");
expect(blocked.compareDocumentPosition(screen.getByTestId('transfer-needs-set-up'))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// Notes, not alerts: an alert would interrupt the status line and say the same thing.
expect(within(blocked).getAllByRole('note')).toHaveLength(2);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(statusLine()).toHaveTextContent("Check done. The workflow can't move to Acme Cloud.");
});
it('lists node types that the linked instance does not have and disables Move', async () => {
await setupReady(
{},
transferPreflight({ nodeTypeCheck: 'checked', missingNodeTypes: ['acme.thing@2'] }),
);
expect(screen.getByText("Acme Cloud doesn't have these node types:")).toBeVisible();
expect(screen.getByText('acme.thing@2')).toBeVisible();
expect(submitButton()).toBeDisabled();
});
});
describe('choices', () => {
it('offers no choice for a workflow that is not live here', async () => {
await setupReady();
expect(screen.queryByText('The copy here')).not.toBeInTheDocument();
expect(screen.queryByTestId('transfer-turn-off-here')).not.toBeInTheDocument();
expect(screen.queryByTestId('transfer-turn-on')).not.toBeInTheDocument();
});
it('offers to turn off the copy here only when the user can turn it off', async () => {
await setupReady({ workflow: workflow({ liveHere: true, canUnpublish: false }) });
expect(screen.queryByTestId('transfer-turn-off-here')).not.toBeInTheDocument();
});
it('offers both choices, off by default, and tells what the picked choices do', async () => {
await setupReady(
{ workflow: workflow({ liveHere: true, canUnpublish: true }), offerTurnOn: true },
transferPreflight({
credentials: [{ name: 'Gmail', type: 'gmailOAuth2', status: 'needs-set-up' }],
}),
);
const turnOffHere = screen.getByRole('checkbox', {
name: 'Turn off the copy on this computer',
});
const turnOn = screen.getByRole('checkbox', { name: 'Turn it on in Acme Cloud' });
expect(screen.getByRole('heading', { name: 'The copy here' })).toBeVisible();
expect(turnOffHere).not.toBeChecked();
expect(turnOn).not.toBeChecked();
const turnsOffHere =
'The workflow turns off here. It runs in Acme Cloud only when the copy there is on.';
const hints = within(screen.getByTestId('transfer-hints'));
await userEvent.click(turnOffHere);
expect(hints.getByText(turnsOffHere)).toBeVisible();
await userEvent.click(turnOn);
expect(screen.queryByText(turnsOffHere)).not.toBeInTheDocument();
expect(
hints.getByText(
"This version can't go live in Acme Cloud until you set up its credentials there.",
),
).toBeVisible();
expect(
hints.getByText(
'The workflow here stays on. Turn it off here after the copy is on in Acme Cloud.',
),
).toBeVisible();
// Screen readers read each new hint.
expect(screen.getByTestId('transfer-hints')).toHaveAttribute('aria-live', 'polite');
});
it('does not offer to turn on the copy unless the opener asks for it', async () => {
await setupReady({ workflow: workflow({ liveHere: true, canUnpublish: true }) });
expect(
screen.getByRole('checkbox', { name: 'Turn off the copy on this computer' }),
).toBeVisible();
expect(screen.queryByTestId('transfer-turn-on')).not.toBeInTheDocument();
});
});
describe('error and retry', () => {
it('shows the server message and checks again on "Try again"', async () => {
api.fetchTransferPreflight
.mockRejectedValueOnce(new ResponseError(UNREACHABLE, { httpStatusCode: 400 }))
.mockResolvedValueOnce(transferPreflight());
renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance() },
});
expect(await screen.findByText(UNREACHABLE)).toBeVisible();
expect(submitButton()).toBeDisabled();
// The notice reads out the error once: no live region around it repeats it.
expect(screen.getByRole('alert')).toHaveTextContent(UNREACHABLE);
expect(screen.getByRole('alert').closest('[aria-live]')).toBeNull();
expect(statusLine()).toBeEmptyDOMElement();
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByTestId('transfer-moves')).toBeVisible();
expect(screen.queryByText(UNREACHABLE)).not.toBeInTheDocument();
expect(api.fetchTransferPreflight).toHaveBeenCalledTimes(2);
// "Try again" went away, so focus moves to Cancel instead of the page.
await waitFor(() => expect(cancelButton()).toHaveFocus());
});
it('keeps focus on "Try again" when the check fails again', async () => {
api.fetchTransferPreflight.mockRejectedValue(new ResponseError(UNREACHABLE));
renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance() },
});
await userEvent.click(await screen.findByRole('button', { name: 'Try again' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Try again' })).toHaveFocus());
expect(api.fetchTransferPreflight).toHaveBeenCalledTimes(2);
});
it('shows its own message when the server gave none', async () => {
api.fetchTransferPreflight.mockRejectedValue(new TypeError('Failed to fetch'));
renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance() },
});
expect(
await screen.findByText("Couldn't check what moves to Acme Cloud. Try again."),
).toBeVisible();
expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument();
});
});
describe('move', () => {
it('moves with the picked choices, then closes and shows a toast with a link to the copy', async () => {
const request = deferred<LinkedInstancePushResult>();
api.moveWorkflow.mockReturnValue(request.promise);
const { emitted, rerender } = await setupReady({
workflow: workflow({ liveHere: true, canUnpublish: true }),
offerTurnOn: true,
});
await userEvent.click(screen.getByRole('checkbox', { name: 'Turn it on in Acme Cloud' }));
await userEvent.click(submitButton());
expect(api.moveWorkflow).toHaveBeenCalledWith(expect.anything(), 'link-1', {
workflowId: 'wf-1',
publish: true,
deactivateLocal: false,
});
expect(submitButton()).toHaveTextContent('Moving…');
expect(cancelButton()).toBeDisabled();
expect(screen.queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument();
// Every control is disabled, so the details keep focus in the dialog.
await waitFor(() => expect(details()).toHaveFocus());
expect(statusLine()).toHaveTextContent('Moving the workflow to Acme Cloud…');
// The status line reads the move, so the button label is not read a second time.
expect(submitButton()).toHaveAttribute('aria-live', 'off');
const result = pushResult({ published: true });
request.resolve(result);
await waitFor(() => expect(emitted('moved')).toEqual([[result]]));
expect(emitted('update:open')).toEqual([[false]]);
expect(toast.showToast).not.toHaveBeenCalled();
await rerender({ open: false });
await waitFor(() => expect(emitted('closed')).toHaveLength(1));
expect(toast.showToast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Moved to Acme Cloud', type: 'success', duration: 0 }),
);
expect(syncLocalTurnOff).not.toHaveBeenCalled();
renderToastMessage();
expect(screen.getByText("It's on in Acme Cloud.")).toBeVisible();
const open = screen.getByRole('link', { name: /Open in Acme Cloud/ });
expect(open).toHaveAttribute('href', 'https://acme.app.n8n.cloud/workflow/remote-wf-1');
expect(open).toHaveAttribute('target', '_blank');
expect(open).toHaveAttribute('rel', 'noopener noreferrer');
});
it('lists what needs setting up in the toast, with a link for each credential', async () => {
api.moveWorkflow.mockResolvedValue(
pushResult({
credentialsNeedingSetup: [{ id: 'cred-1', name: 'Gmail account', type: 'gmailOAuth2' }],
warnings: [
'The workflow is in Acme Cloud, but 1 credential that it uses there has no value.',
],
}),
);
const { rerender } = await setupReady();
await userEvent.click(submitButton());
await rerender({ open: false });
await waitFor(() => expect(toast.showToast).toHaveBeenCalled());
expect(toast.showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
renderToastMessage();
expect(screen.getByText("It's off in Acme Cloud.")).toBeVisible();
expect(screen.getByText('Needs setting up in Acme Cloud:')).toBeVisible();
expect(screen.getByText('Gmail account')).toBeVisible();
const setUp = screen.getByRole('link', {
name: 'Set up in Acme Cloud: Gmail account (opens in a new tab)',
});
expect(setUp).toHaveAttribute('href', 'https://acme.app.n8n.cloud/home/credentials/cred-1');
expect(setUp).toHaveAttribute('rel', 'noopener noreferrer');
expect(
screen.getByText(
'The workflow is in Acme Cloud, but 1 credential that it uses there has no value.',
),
).toBeVisible();
});
it('turns off the workflow here when the user picked it, and updates the editor', async () => {
api.moveWorkflow.mockResolvedValue(pushResult({ published: true, localDeactivated: true }));
const { rerender } = await setupReady({
workflow: workflow({ liveHere: true, canUnpublish: true }),
offerTurnOn: true,
});
await userEvent.click(
screen.getByRole('checkbox', { name: 'Turn off the copy on this computer' }),
);
await userEvent.click(screen.getByRole('checkbox', { name: 'Turn it on in Acme Cloud' }));
await userEvent.click(submitButton());
await waitFor(() => expect(syncLocalTurnOff).toHaveBeenCalledWith('wf-1'));
expect(api.moveWorkflow).toHaveBeenCalledWith(expect.anything(), 'link-1', {
workflowId: 'wf-1',
publish: true,
deactivateLocal: true,
});
await rerender({ open: false });
await waitFor(() => expect(toast.showToast).toHaveBeenCalled());
renderToastMessage();
expect(screen.getByText(/It's off on this computer\./)).toBeVisible();
});
it('stays open with the server message when the move fails, and keeps focus on Move', async () => {
api.moveWorkflow.mockRejectedValue(new ResponseError(UNREACHABLE, { httpStatusCode: 400 }));
const { emitted } = await setupReady();
await userEvent.click(submitButton());
const error = await screen.findByTestId('transfer-move-error');
expect(error).toHaveTextContent(UNREACHABLE);
expect(screen.getByRole('alert')).toBe(error);
expect(error.closest('[aria-live]')).toBeNull();
// The details scroll. The error stays in view, just before the button that failed.
expect(details()).not.toContainElement(error);
expect(error.compareDocumentPosition(submitButton())).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(statusLine()).toBeEmptyDOMElement();
expect(emitted('update:open')).toBeUndefined();
expect(emitted('moved')).toBeUndefined();
expect(submitButton()).toBeEnabled();
await waitFor(() => expect(submitButton()).toHaveFocus());
});
it('shows its own message when a failed move gives none', async () => {
api.moveWorkflow.mockRejectedValue(new TypeError('Failed to fetch'));
await setupReady();
await userEvent.click(submitButton());
expect(await screen.findByTestId('transfer-move-error')).toHaveTextContent(
"Couldn't move the workflow to Acme Cloud. Try again.",
);
});
it('does not say that the moved version is on when the move did not publish it', async () => {
const warning =
'The new version is not live, because the source workflow does not publish this version. An earlier version stays live.';
api.moveWorkflow.mockResolvedValue(
pushResult({ created: false, published: true, warnings: [warning] }),
);
const { rerender } = await setupReady({
workflow: workflow({ liveHere: true, canUnpublish: true }),
});
await userEvent.click(submitButton());
expect(api.moveWorkflow).toHaveBeenCalledWith(expect.anything(), 'link-1', {
workflowId: 'wf-1',
publish: false,
deactivateLocal: false,
});
await rerender({ open: false });
await waitFor(() => expect(toast.showToast).toHaveBeenCalled());
renderToastMessage();
expect(
screen.getByText(
'A version of it stays on in Acme Cloud. Open it there to see which version runs.',
),
).toBeVisible();
expect(screen.queryByText("It's on in Acme Cloud.")).not.toBeInTheDocument();
expect(screen.getByText(warning)).toBeVisible();
});
});
describe('keyboard', () => {
it('closes on Escape when no move runs', async () => {
const { emitted } = await setupReady();
await userEvent.keyboard('{Escape}');
expect(emitted('update:open')).toEqual([[false]]);
});
it('ignores Escape while the move runs', async () => {
const request = deferred<LinkedInstancePushResult>();
api.moveWorkflow.mockReturnValue(request.promise);
const { emitted } = await setupReady();
await userEvent.click(submitButton());
await userEvent.keyboard('{Escape}');
expect(emitted('update:open')).toBeUndefined();
request.resolve(pushResult());
await waitFor(() => expect(emitted('update:open')).toEqual([[false]]));
});
it('keeps focus inside the dialog when Tab passes the last control', async () => {
await setupReady();
await waitFor(() => expect(cancelButton()).toHaveFocus());
await userEvent.tab();
expect(submitButton()).toHaveFocus();
await userEvent.tab();
expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
await userEvent.tab();
expect(cancelButton()).toHaveFocus();
await userEvent.tab({ shift: true });
expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
});
it('lets keyboard users scroll the details only when they do not fit', async () => {
const notify: Array<() => void> = [];
vi.stubGlobal(
'ResizeObserver',
class {
constructor(onResize: () => void) {
notify.push(onResize);
}
observe() {}
unobserve() {}
disconnect() {}
},
);
await setupReady();
await waitFor(() => expect(cancelButton()).toHaveFocus());
expect(details()).toHaveAttribute('tabindex', '-1');
Object.defineProperties(details(), {
scrollHeight: { configurable: true, value: 900 },
clientHeight: { configurable: true, value: 400 },
});
for (const onResize of notify) onResize();
await waitFor(() => expect(details()).toHaveAttribute('tabindex', '0'));
await userEvent.tab({ shift: true });
expect(details()).toHaveFocus();
});
it('tells the opener when it has left, so the opener can move focus', async () => {
const { emitted, rerender } = await setupReady();
await rerender({ open: false });
await waitFor(() => expect(emitted('closed')).toHaveLength(1));
expect(screen.queryByTestId('transfer-workflow-dialog')).not.toBeInTheDocument();
expect(toast.showToast).not.toHaveBeenCalled();
});
});
it('ignores a check that ends after the dialog opened again', async () => {
const first = deferred<LinkedInstanceTransferPreflight>();
api.fetchTransferPreflight
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(transferPreflight({ moves: { nodes: 9 } }));
const { rerender } = renderDialog({
pinia: createTestingPinia({ stubActions: false }),
props: { open: true, workflow: workflow(), instance: linkedInstance() },
});
await screen.findByTestId('transfer-checking');
await rerender({ open: false });
await rerender({ open: true });
expect(await screen.findByText('9 nodes')).toBeVisible();
first.resolve(transferPreflight({ moves: { nodes: 2 } }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByText('9 nodes')).toBeVisible();
expect(screen.queryByText('2 nodes')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,351 @@
import type { LinkedInstanceSummary } from '@n8n/api-types';
import type { PermissionsRecord } from '@n8n/permissions';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import ActionsDropdownMenu from '@/app/components/MainHeader/ActionsDropdownMenu.vue';
import { AutoSaveState, MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowSaveStore } from '@/app/stores/workflowSave.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
deferred,
linkedInstance,
transferPreflight,
} from '../../__tests__/linkedInstances.fixtures';
import type * as LinkApi from '../../linkedInstances.api';
import type * as TransferApi from '../transfer.api';
const linkApi = vi.hoisted(() => ({
fetchLinkedInstances: vi.fn<typeof LinkApi.fetchLinkedInstances>(),
instancePath: (id: string) => `/linked-instances/${id}`,
}));
vi.mock('@/features/linkedInstances/linkedInstances.api', () => linkApi);
const transferApi = vi.hoisted(() => ({
fetchTransferPreflight: vi.fn<typeof TransferApi.fetchTransferPreflight>(),
moveWorkflow: vi.fn<typeof TransferApi.moveWorkflow>(),
}));
vi.mock('@/features/linkedInstances/transfer/transfer.api', () => transferApi);
const saving = vi.hoisted(() => ({
saveCurrentWorkflow: vi.fn(async () => true),
cancelAutoSave: vi.fn(),
}));
vi.mock('@/app/composables/useWorkflowSaving', () => ({ useWorkflowSaving: () => saving }));
const confirm = vi.hoisted(() => vi.fn());
vi.mock('@n8n/design-system', async () => ({
...(await vi.importActual<object>('@n8n/design-system')),
useMessage: () => ({ confirm }),
}));
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showToast: vi.fn(), showMessage: vi.fn(), showError: vi.fn() }),
}));
vi.mock('@/app/composables/useDependencies', () => ({
useDependencies: () => ({
getDependencies: () => undefined,
fetchDependencies: vi.fn(),
fetchDependencyCounts: vi.fn(),
hasDependencies: () => false,
}),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRoute: () => ({
name: 'NodeViewExisting',
params: { workflowId: 'wf-1' },
query: {},
meta: {},
}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
resolve: vi.fn(() => ({ href: '' })),
currentRoute: { value: { params: {}, query: {} } },
}),
}));
const acme = linkedInstance();
const staging = linkedInstance({ id: 'link-2', name: 'Staging', baseUrl: 'http://localhost:5680' });
const ALL_RIGHTS: PermissionsRecord['workflow'] = {
read: true,
update: true,
export: true,
publish: true,
unpublish: true,
};
const renderMenu = createComponentRenderer(ActionsDropdownMenu, {
global: { stubs: { WorkflowProductionChecklist: true } },
});
interface SetupOptions {
modules?: string[];
scopes?: Array<'instanceAi:message'>;
/** The links, or the error of the first read. */
links?: LinkedInstanceSummary[] | Error;
live?: boolean;
props?: Partial<{
workflowPermissions: PermissionsRecord['workflow'];
isNewWorkflow: boolean;
isArchived: boolean;
}>;
}
function setup(options: SetupOptions = {}) {
const pinia = createTestingPinia({ stubActions: false });
const settingsStore = useSettingsStore();
settingsStore.settings = {
...settingsStore.settings,
activeModules: options.modules ?? ['linked-instances'],
} as typeof settingsStore.settings;
useRBACStore().setGlobalScopes(options.scopes ?? ['instanceAi:message']);
useWorkflowsStore().setWorkflowId('wf-1');
useWorkflowDocumentStore(createWorkflowDocumentId('wf-1')).setActiveState({
activeVersionId: options.live ? 'version-1' : null,
activeVersion: null,
});
const links = options.links ?? [acme, staging];
if (links instanceof Error) linkApi.fetchLinkedInstances.mockRejectedValue(links);
else linkApi.fetchLinkedInstances.mockResolvedValue(links);
transferApi.fetchTransferPreflight.mockResolvedValue(transferPreflight());
renderMenu({
pinia,
props: {
workflowPermissions: ALL_RIGHTS,
isNewWorkflow: false,
isArchived: false,
id: 'wf-1',
name: 'Daily report',
tags: [],
...options.props,
},
});
return { uiStore: useUIStore(), saveStore: useWorkflowSaveStore() };
}
const menuTrigger = () => screen.getByRole('button', { name: 'More actions' });
const moveItemId = (linkId: string) => `workflow-menu-item-move-to-linked-instance:${linkId}`;
const moveItem = (linkId: string) => screen.queryByTestId(moveItemId(linkId));
const findMoveItem = async (linkId: string) => await screen.findByTestId(moveItemId(linkId));
async function openMenu() {
await waitFor(() => expect(linkApi.fetchLinkedInstances).toHaveBeenCalled());
await userEvent.click(menuTrigger());
await screen.findByTestId('workflow-menu-item-download');
}
describe('workflow menu: Move to a linked instance', () => {
beforeEach(() => {
vi.clearAllMocks();
saving.saveCurrentWorkflow.mockResolvedValue(true);
});
describe('entries', () => {
it('lists one "Move to {name}" entry for each link', async () => {
setup();
await openMenu();
await waitFor(() => expect(moveItem('link-1')).toHaveTextContent('Move to Acme Cloud'));
expect(moveItem('link-2')).toHaveTextContent('Move to Staging');
});
it.each<[string, SetupOptions]>([
['the linked-instances module is off', { modules: [] }],
['the user lacks the scope of the routes', { scopes: [] }],
['the user has no link', { links: [] }],
[
'the user cannot export the workflow',
{ props: { workflowPermissions: { ...ALL_RIGHTS, export: false } } },
],
['the workflow is archived', { props: { isArchived: true } }],
['the workflow is not saved yet', { props: { isNewWorkflow: true } }],
['the links cannot be read', { links: new Error('Network error') }],
])('shows no move when %s', async (_label, options) => {
setup(options);
await userEvent.click(menuTrigger());
await screen.findByTestId('workflow-menu-item-download');
// A read of the links that runs now would add the entries after this.
await flushPromises();
expect(moveItem('link-1')).not.toBeInTheDocument();
});
it('does not ask the server for links while the module is off', async () => {
setup({ modules: [] });
await userEvent.click(menuTrigger());
await screen.findByTestId('workflow-menu-item-download');
expect(linkApi.fetchLinkedInstances).not.toHaveBeenCalled();
});
it('reads the links again each time the menu opens', async () => {
setup();
await openMenu();
await findMoveItem('link-1');
await userEvent.keyboard('{Escape}');
linkApi.fetchLinkedInstances.mockResolvedValue([staging]);
await userEvent.click(menuTrigger());
await waitFor(() => expect(moveItem('link-1')).not.toBeInTheDocument());
expect(moveItem('link-2')).toBeInTheDocument();
});
it('keeps the entries when a later read fails', async () => {
setup();
await openMenu();
await findMoveItem('link-1');
await userEvent.keyboard('{Escape}');
linkApi.fetchLinkedInstances.mockRejectedValue(new Error('Network error'));
const reads = linkApi.fetchLinkedInstances.mock.calls.length;
await userEvent.click(menuTrigger());
await waitFor(() => expect(linkApi.fetchLinkedInstances).toHaveBeenCalledTimes(reads + 1));
await flushPromises();
expect(moveItem('link-1')).toBeInTheDocument();
expect(moveItem('link-2')).toBeInTheDocument();
});
});
describe('opening the dialog', () => {
it('opens the dialog for the chosen link when the editor has no changes', async () => {
setup();
await openMenu();
await userEvent.click(await findMoveItem('link-2'));
expect(
await screen.findByRole('dialog', { name: 'Move "Daily report" to Staging?' }),
).toBeVisible();
expect(confirm).not.toHaveBeenCalled();
expect(saving.saveCurrentWorkflow).not.toHaveBeenCalled();
expect(transferApi.fetchTransferPreflight).toHaveBeenCalledWith(expect.anything(), 'link-2', {
workflowId: 'wf-1',
});
});
it('offers to turn off a live workflow here, but not to turn on the copy there', async () => {
setup({ live: true });
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
expect(
await screen.findByRole('checkbox', { name: 'Turn off the copy on this computer' }),
).toBeVisible();
// The editor has no request for a live automation. The Assistant can make one.
expect(
screen.queryByRole('checkbox', { name: 'Turn it on in Acme Cloud' }),
).not.toBeInTheDocument();
});
it('puts focus back on the menu button when the dialog closes', async () => {
setup();
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
await screen.findByTestId('transfer-moves');
await userEvent.click(screen.getByTestId('transfer-cancel'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(menuTrigger()).toHaveFocus());
});
});
describe('unsaved changes', () => {
it('asks to save first, saves, then opens the dialog', async () => {
const { uiStore } = setup();
uiStore.markStateDirty();
confirm.mockResolvedValue(MODAL_CONFIRM);
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
expect(await screen.findByRole('dialog')).toBeVisible();
expect(confirm).toHaveBeenCalledWith(
'Acme Cloud gets the saved version of this workflow. Save your changes so that they move too.',
'Save your changes before moving?',
expect.objectContaining({
confirmButtonText: 'Save and continue',
cancelButtonText: 'Cancel',
}),
);
expect(saving.saveCurrentWorkflow).toHaveBeenCalledTimes(1);
});
it('moves nothing and puts focus back on the menu button when the user cancels', async () => {
const { uiStore } = setup();
uiStore.markStateDirty();
confirm.mockResolvedValue(MODAL_CANCEL);
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
await waitFor(() => expect(menuTrigger()).toHaveFocus());
expect(saving.saveCurrentWorkflow).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(transferApi.fetchTransferPreflight).not.toHaveBeenCalled();
});
it('opens no dialog when the save fails', async () => {
const { uiStore } = setup();
uiStore.markStateDirty();
confirm.mockResolvedValue(MODAL_CONFIRM);
saving.saveCurrentWorkflow.mockResolvedValue(false);
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
await waitFor(() => expect(saving.saveCurrentWorkflow).toHaveBeenCalled());
await waitFor(() => expect(menuTrigger()).toHaveFocus());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('waits for an autosave that runs and asks nothing when it saved every change', async () => {
const { uiStore, saveStore } = setup();
const autosave = deferred<boolean>();
uiStore.markStateDirty();
saveStore.setAutoSaveState(AutoSaveState.InProgress);
saveStore.setPendingSave(autosave.promise);
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
uiStore.markStateClean();
autosave.resolve(true);
expect(await screen.findByRole('dialog')).toBeVisible();
expect(confirm).not.toHaveBeenCalled();
expect(saving.saveCurrentWorkflow).not.toHaveBeenCalled();
});
it('stops a scheduled autosave and saves at once', async () => {
const { uiStore, saveStore } = setup();
uiStore.markStateDirty();
saveStore.setAutoSaveState(AutoSaveState.Scheduled);
confirm.mockResolvedValue(MODAL_CONFIRM);
await openMenu();
await userEvent.click(await findMoveItem('link-1'));
expect(await screen.findByRole('dialog')).toBeVisible();
expect(saving.cancelAutoSave).toHaveBeenCalled();
expect(saving.saveCurrentWorkflow).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,74 @@
import { createTestingPinia } from '@pinia/testing';
import { createTestWorkflow } from '@/__tests__/mocks';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { syncLocalTurnOff } from '../syncLocalTurnOff';
function liveDocument(workflowId: string) {
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
documentStore.setActiveState({ activeVersionId: 'version-1', activeVersion: null });
documentStore.setChecksum('checksum-before');
documentStore.setVersionData({ versionId: 'version-1', name: 'v1', description: null });
return documentStore;
}
describe('syncLocalTurnOff', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('shows the open workflow as off and reads its new checksum, so the next save works', async () => {
const workflowsStore = useWorkflowsStore();
workflowsStore.setWorkflowId('wf-1');
const documentStore = liveDocument('wf-1');
const setInactive = vi.spyOn(workflowsStore, 'setWorkflowInactive');
const fetchWorkflow = vi.spyOn(useWorkflowsListStore(), 'fetchWorkflow').mockResolvedValue(
createTestWorkflow({
id: 'wf-1',
versionId: 'version-1',
activeVersionId: null,
checksum: 'checksum-after',
}),
);
await syncLocalTurnOff('wf-1');
expect(setInactive).toHaveBeenCalledWith('wf-1');
expect(documentStore.active).toBe(false);
expect(fetchWorkflow).toHaveBeenCalledWith('wf-1');
expect(documentStore.checksum).toBe('checksum-after');
expect(documentStore.versionId).toBe('version-1');
// The name of the version stays, as after a turn-off in the editor.
expect(documentStore.versionData?.name).toBe('v1');
});
it('marks a workflow that is not open as off without reading it', async () => {
useWorkflowsStore().setWorkflowId('wf-other');
const documentStore = liveDocument('wf-1');
const fetchWorkflow = vi.spyOn(useWorkflowsListStore(), 'fetchWorkflow');
await syncLocalTurnOff('wf-1');
expect(documentStore.active).toBe(false);
expect(fetchWorkflow).not.toHaveBeenCalled();
expect(documentStore.checksum).toBe('checksum-before');
});
it('still shows the workflow as off when the new checksum cannot be read', async () => {
useWorkflowsStore().setWorkflowId('wf-1');
const documentStore = liveDocument('wf-1');
vi.spyOn(useWorkflowsListStore(), 'fetchWorkflow').mockRejectedValue(
new Error('Network error'),
);
await expect(syncLocalTurnOff('wf-1')).resolves.toBeUndefined();
expect(documentStore.active).toBe(false);
expect(documentStore.checksum).toBe('checksum-before');
});
});

View File

@ -0,0 +1,135 @@
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import { fakeToken, pushResult, transferPreflight } from '../../__tests__/linkedInstances.fixtures';
import { fetchTransferPreflight, moveWorkflow } from '../transfer.api';
vi.mock('@n8n/rest-api-client', () => ({
makeRestApiRequest: vi.fn(),
}));
const context = { baseUrl: '/rest', pushRef: 'push-1' } as IRestApiContext;
describe('transfer.api', () => {
beforeEach(() => {
vi.mocked(makeRestApiRequest).mockReset();
});
describe('fetchTransferPreflight', () => {
it('asks the preflight route of the link for the workflow', async () => {
vi.mocked(makeRestApiRequest).mockResolvedValue(transferPreflight());
await expect(
fetchTransferPreflight(context, 'link-1', { workflowId: 'wf-1' }),
).resolves.toEqual(transferPreflight());
expect(makeRestApiRequest).toHaveBeenCalledWith(
context,
'POST',
'/linked-instances/link-1/transfer/preflight',
{ workflowId: 'wf-1' },
);
});
it('encodes the link id in the path', async () => {
vi.mocked(makeRestApiRequest).mockResolvedValue(transferPreflight());
await fetchTransferPreflight(context, 'a/b?c', { workflowId: 'wf-1' });
expect(vi.mocked(makeRestApiRequest).mock.calls[0][2]).toBe(
'/linked-instances/a%2Fb%3Fc/transfer/preflight',
);
});
it('reads values that this client does not know as "unknown"', async () => {
vi.mocked(makeRestApiRequest).mockResolvedValue({
...transferPreflight(),
nodeTypeCheck: 'partial',
credentials: [{ name: 'Slack', type: 'slackApi', status: 'expired' }],
});
const preflight = await fetchTransferPreflight(context, 'link-1', { workflowId: 'wf-1' });
expect(preflight.nodeTypeCheck).toBe('unknown');
expect(preflight.credentials).toEqual([
{ name: 'Slack', type: 'slackApi', status: 'unknown' },
]);
});
it('keeps the personal project as null and a hidden sub-workflow name as null', async () => {
const response = transferPreflight({
targetProject: null,
subWorkflowCalls: [{ id: 'wf-9', name: null }],
});
vi.mocked(makeRestApiRequest).mockResolvedValue(response);
await expect(
fetchTransferPreflight(context, 'link-1', { workflowId: 'wf-1' }),
).resolves.toEqual(response);
});
it('drops fields that the preflight does not name', async () => {
const extra = fakeToken();
vi.mocked(makeRestApiRequest).mockResolvedValue({ ...transferPreflight(), token: extra });
const preflight = await fetchTransferPreflight(context, 'link-1', { workflowId: 'wf-1' });
expect(JSON.stringify(preflight)).not.toContain(extra);
});
it.each([
['a negative node count', { ...transferPreflight(), moves: { nodes: -1 } }],
['a node count that is not whole', { ...transferPreflight(), moves: { nodes: 1.5 } }],
['no credential list', { ...transferPreflight(), credentials: undefined }],
['no sub-workflow list', { ...transferPreflight(), subWorkflowCalls: null }],
])('rejects a response with %s', async (_label, response) => {
vi.mocked(makeRestApiRequest).mockResolvedValue(response);
await expect(
fetchTransferPreflight(context, 'link-1', { workflowId: 'wf-1' }),
).rejects.toThrow();
});
});
describe('moveWorkflow', () => {
it('sends the move with its choices to the transfer route of the link', async () => {
vi.mocked(makeRestApiRequest).mockResolvedValue(pushResult());
const payload = { workflowId: 'wf-1', publish: true, deactivateLocal: false };
await expect(moveWorkflow(context, 'link-1', payload)).resolves.toEqual(pushResult());
expect(makeRestApiRequest).toHaveBeenCalledWith(
context,
'POST',
'/linked-instances/link-1/transfer',
payload,
);
});
it('keeps a remote URL that is not http(s), because the move worked', async () => {
const result = pushResult({ remoteUrl: 'ftp://acme.example.test/workflow/1' });
vi.mocked(makeRestApiRequest).mockResolvedValue(result);
await expect(moveWorkflow(context, 'link-1', { workflowId: 'wf-1' })).resolves.toEqual(
result,
);
});
it('drops fields that the result does not name', async () => {
const extra = fakeToken();
vi.mocked(makeRestApiRequest).mockResolvedValue({ ...pushResult(), token: extra });
const result = await moveWorkflow(context, 'link-1', { workflowId: 'wf-1' });
expect(result).toEqual(pushResult());
});
it.each([
['no warnings list', { ...pushResult(), warnings: undefined }],
['a credential without an id', { ...pushResult(), credentialsNeedingSetup: [{ name: 'A' }] }],
['a text in place of a flag', { ...pushResult(), localDeactivated: 'yes' }],
])('rejects a result with %s', async (_label, response) => {
vi.mocked(makeRestApiRequest).mockResolvedValue(response);
await expect(moveWorkflow(context, 'link-1', { workflowId: 'wf-1' })).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,256 @@
import {
LINKED_INSTANCE_TRANSFER_CREDENTIAL_STATUSES,
type LinkedInstanceTransferPreflight,
} from '@n8n/api-types';
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import {
transferDialogState,
transferHints,
transferOptions,
transferRequest,
transferStatus,
type TransferChoice,
type TransferMoveStatus,
type TransferOptionsInput,
} from '../transferDialogState';
const credentialArb = fc.record({
name: fc.string({ maxLength: 20 }),
type: fc.string({ maxLength: 20 }),
status: fc.constantFrom(...LINKED_INSTANCE_TRANSFER_CREDENTIAL_STATUSES),
});
const preflightArb: fc.Arbitrary<LinkedInstanceTransferPreflight> = fc.record({
workflowName: fc.string(),
moves: fc.record({ nodes: fc.nat({ max: 500 }) }),
nodeTypeCheck: fc.constantFrom('checked' as const, 'unknown' as const),
missingNodeTypes: fc.array(fc.string({ minLength: 1, maxLength: 30 }), { maxLength: 4 }),
credentials: fc.array(credentialArb, { maxLength: 8 }),
targetProject: fc.option(fc.record({ id: fc.uuid(), name: fc.string() }), { nil: null }),
subWorkflowCalls: fc.array(
fc.record({ id: fc.uuid(), name: fc.option(fc.string(), { nil: null }) }),
{ maxLength: 4 },
),
});
const optionsArb = fc.record({
liveHere: fc.boolean(),
canUnpublish: fc.boolean(),
offerTurnOn: fc.boolean(),
});
const choiceArb = fc.record({ turnOffHere: fc.boolean(), turnOn: fc.boolean() });
describe('transferDialogState (property)', () => {
it('can move exactly when no node type is missing and no other workflow is called', () => {
fc.assert(
fc.property(preflightArb, (preflight) => {
const { canMove } = transferDialogState(preflight);
expect(canMove).toBe(
preflight.missingNodeTypes.length === 0 && preflight.subWorkflowCalls.length === 0,
);
}),
);
});
it('cannot move while a node type is missing, whatever else the preflight says', () => {
fc.assert(
fc.property(preflightArb, fc.string({ minLength: 1 }), (preflight, nodeType) => {
const state = transferDialogState({
...preflight,
missingNodeTypes: [...preflight.missingNodeTypes, nodeType],
});
expect(state.canMove).toBe(false);
}),
);
});
it('puts every credential in exactly one list and keeps the order', () => {
fc.assert(
fc.property(preflightArb, (preflight) => {
const state = transferDialogState(preflight);
const matched = preflight.credentials.filter(({ status }) => status === 'matched');
const others = preflight.credentials.filter(({ status }) => status !== 'matched');
expect(state.matchedCredentials).toEqual(matched.map(({ name }) => name));
expect(state.needsSetUp).toEqual(others);
}),
);
});
it('warns exactly when the credential check could not tell', () => {
fc.assert(
fc.property(preflightArb, (preflight) => {
const { warnings, nodeTypesChecked } = transferDialogState(preflight);
const unknown = preflight.credentials.some(({ status }) => status === 'unknown');
expect(warnings).toEqual(unknown ? ['credentialsUnchecked'] : []);
expect(nodeTypesChecked).toBe(preflight.nodeTypeCheck === 'checked');
}),
);
});
it('shows the counts and the project of the preflight', () => {
fc.assert(
fc.property(preflightArb, (preflight) => {
const state = transferDialogState(preflight);
expect(state.nodeCount).toBe(preflight.moves.nodes);
expect(state.targetProjectName).toBe(preflight.targetProject?.name ?? null);
expect(state.missingNodeTypes).toEqual(preflight.missingNodeTypes);
expect(state.subWorkflowCalls).toEqual(preflight.subWorkflowCalls);
}),
);
});
});
describe('transferRequest (property)', () => {
it('publishes and turns off only what the dialog offered and the user picked', () => {
fc.assert(
fc.property(fc.uuid(), optionsArb, choiceArb, (workflowId, input, choice) => {
const options = transferOptions(input);
const request = transferRequest(workflowId, options, choice);
expect(request.workflowId).toBe(workflowId);
expect(request.publish).toBe(input.offerTurnOn && choice.turnOn);
expect(request.deactivateLocal).toBe(
input.liveHere && input.canUnpublish && choice.turnOffHere,
);
}),
);
});
it('never turns off a workflow that is not live here', () => {
fc.assert(
fc.property(fc.uuid(), optionsArb, choiceArb, (workflowId, input, choice) => {
const options = transferOptions({ ...input, liveHere: false });
expect(transferRequest(workflowId, options, choice).deactivateLocal).toBe(false);
}),
);
});
});
describe('transferHints (property)', () => {
const hintsFor = (
preflight: LinkedInstanceTransferPreflight,
input: TransferOptionsInput,
choice: TransferChoice,
) => {
const options = transferOptions(input);
return {
request: transferRequest('wf-1', options, choice),
hints: transferHints(transferDialogState(preflight), options, choice),
};
};
it('says that the workflow turns off here exactly when the move turns it off and does not publish', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { request, hints } = hintsFor(preflight, input, choice);
expect(hints.includes('turnsOffHere')).toBe(request.deactivateLocal && !request.publish);
}),
);
});
it('shows no hint about the copy there unless the move asks to publish it', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { request, hints } = hintsFor(preflight, input, choice);
if (!request.publish) {
expect(hints).toEqual(request.deactivateLocal ? ['turnsOffHere'] : []);
}
}),
);
});
it('warns that this version stays off exactly when a publish meets an empty credential', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { request, hints } = hintsFor(preflight, input, choice);
const emptyCredential = preflight.credentials.some(
({ status }) => status === 'needs-set-up',
);
expect(hints.includes('notLiveUntilSetUp')).toBe(request.publish && emptyCredential);
}),
);
});
it('says that the workflow here stays on only when the move asks to turn it off and to publish', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { request, hints } = hintsFor(preflight, input, choice);
const staysOn =
hints.includes('staysOnHere') || hints.includes('nodeTypesUncheckedStaysOnHere');
if (staysOn) expect(request.deactivateLocal && request.publish).toBe(true);
if (hints.includes('staysOnHere')) expect(hints).toContain('notLiveUntilSetUp');
}),
);
});
it('mentions the node type check only for a publish that no empty credential blocks', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { request, hints } = hintsFor(preflight, input, choice);
const nodeTypeHint =
hints.includes('nodeTypesUnchecked') || hints.includes('nodeTypesUncheckedStaysOnHere');
expect(nodeTypeHint).toBe(
request.publish &&
preflight.nodeTypeCheck === 'unknown' &&
!hints.includes('notLiveUntilSetUp'),
);
}),
);
});
it('never repeats a hint', () => {
fc.assert(
fc.property(preflightArb, optionsArb, choiceArb, (preflight, input, choice) => {
const { hints } = hintsFor(preflight, input, choice);
expect(new Set(hints).size).toBe(hints.length);
}),
);
});
});
describe('transferStatus (property)', () => {
const moveArb = fc.constantFrom<TransferMoveStatus>('idle', 'moving', 'failed');
it('says "moving" while the move runs, and nothing while an error shows', () => {
fc.assert(
fc.property(preflightArb, moveArb, (preflight, move) => {
const status = transferStatus(transferDialogState(preflight), move);
expect(status.kind === 'moving').toBe(move === 'moving');
expect(status.kind === 'silent').toBe(move === 'failed');
}),
);
});
it('says that the workflow cannot move exactly when Move is disabled by the check', () => {
fc.assert(
fc.property(preflightArb, (preflight) => {
const state = transferDialogState(preflight);
const status = transferStatus(state, 'idle');
expect(status.kind === 'cannotMove').toBe(!state.canMove);
if (status.kind === 'ready') {
expect(status.nodeCount).toBe(preflight.moves.nodes);
expect(status.needsSetUp).toBe(
preflight.credentials.filter(({ status: s }) => s !== 'matched').length,
);
}
}),
);
});
});

View File

@ -0,0 +1,320 @@
import { transferPreflight } from '../../__tests__/linkedInstances.fixtures';
import {
transferDialogState,
transferHints,
transferOptions,
transferRequest,
transferStatus,
type TransferChoice,
type TransferOptions,
} from '../transferDialogState';
const credential = (name: string, status: 'matched' | 'needs-set-up' | 'unknown') => ({
name,
type: `${name.toLowerCase()}Api`,
status,
});
describe('transferDialogState', () => {
it('counts the nodes and names the target project', () => {
const state = transferDialogState(
transferPreflight({ moves: { nodes: 7 }, targetProject: { id: 'p-2', name: 'Ops' } }),
);
expect(state.nodeCount).toBe(7);
expect(state.targetProjectName).toBe('Ops');
});
it('names no project when the move goes to the personal project', () => {
const state = transferDialogState(transferPreflight({ targetProject: null }));
expect(state.targetProjectName).toBeNull();
});
it('lists matched credentials by name and the others as needing set-up, in order', () => {
const state = transferDialogState(
transferPreflight({
nodeTypeCheck: 'checked',
credentials: [
credential('Slack', 'matched'),
credential('Gmail', 'needs-set-up'),
credential('Notion', 'unknown'),
credential('Jira', 'matched'),
],
}),
);
expect(state.matchedCredentials).toEqual(['Slack', 'Jira']);
expect(state.needsSetUp).toEqual([
{ name: 'Gmail', type: 'gmailApi', status: 'needs-set-up' },
{ name: 'Notion', type: 'notionApi', status: 'unknown' },
]);
});
it('can move a workflow with nothing under "Can\'t move"', () => {
const state = transferDialogState(transferPreflight());
expect(state.canMove).toBe(true);
expect(state.missingNodeTypes).toEqual([]);
expect(state.subWorkflowCalls).toEqual([]);
});
it('cannot move a workflow that uses a node type the linked instance does not have', () => {
const state = transferDialogState(
transferPreflight({ nodeTypeCheck: 'checked', missingNodeTypes: ['n8n-nodes-acme.thing@1'] }),
);
expect(state.canMove).toBe(false);
expect(state.missingNodeTypes).toEqual(['n8n-nodes-acme.thing@1']);
});
it('cannot move a workflow that calls other workflows by ID, also hidden ones', () => {
const calls = [
{ id: 'wf-2', name: 'Enrich lead' },
{ id: 'wf-3', name: null },
];
const state = transferDialogState(transferPreflight({ subWorkflowCalls: calls }));
expect(state.canMove).toBe(false);
expect(state.subWorkflowCalls).toEqual(calls);
});
it('copies the lists, so a change to the state does not change the preflight', () => {
const preflight = transferPreflight({
missingNodeTypes: ['a@1'],
subWorkflowCalls: [{ id: 'wf-2', name: 'Sub' }],
});
const state = transferDialogState(preflight);
state.missingNodeTypes.push('b@1');
state.subWorkflowCalls.pop();
expect(preflight.missingNodeTypes).toEqual(['a@1']);
expect(preflight.subWorkflowCalls).toHaveLength(1);
});
it('tells whether the linked instance listed its node types', () => {
expect(
transferDialogState(transferPreflight({ nodeTypeCheck: 'checked' })).nodeTypesChecked,
).toBe(true);
expect(
transferDialogState(transferPreflight({ nodeTypeCheck: 'unknown' })).nodeTypesChecked,
).toBe(false);
});
describe('warnings', () => {
it('does not warn about node types, because only the choice to turn the copy on needs them', () => {
expect(transferDialogState(transferPreflight({ nodeTypeCheck: 'unknown' })).warnings).toEqual(
[],
);
});
it('warns when the linked instance did not tell the state of a credential', () => {
const state = transferDialogState(
transferPreflight({
nodeTypeCheck: 'checked',
credentials: [credential('Slack', 'matched'), credential('Gmail', 'unknown')],
}),
);
expect(state.warnings).toEqual(['credentialsUnchecked']);
});
it('does not warn about credentials that only need set-up', () => {
const state = transferDialogState(
transferPreflight({
nodeTypeCheck: 'checked',
credentials: [credential('Gmail', 'needs-set-up')],
}),
);
expect(state.warnings).toEqual([]);
});
it('warns once, also when several credentials were not checked', () => {
const state = transferDialogState(
transferPreflight({
credentials: [credential('Gmail', 'unknown'), credential('Notion', 'unknown')],
}),
);
expect(state.warnings).toEqual(['credentialsUnchecked']);
});
});
});
describe('transferOptions', () => {
it('offers to turn off the workflow here only when it is live and the user can turn it off', () => {
const offered = (liveHere: boolean, canUnpublish: boolean) =>
transferOptions({ liveHere, canUnpublish, offerTurnOn: false }).showTurnOffHere;
expect(offered(true, true)).toBe(true);
expect(offered(true, false)).toBe(false);
expect(offered(false, true)).toBe(false);
expect(offered(false, false)).toBe(false);
});
it('offers to turn on the copy only when the user asked for a live automation', () => {
expect(
transferOptions({ liveHere: false, canUnpublish: false, offerTurnOn: true }).showTurnOn,
).toBe(true);
expect(
transferOptions({ liveHere: true, canUnpublish: true, offerTurnOn: false }).showTurnOn,
).toBe(false);
});
});
describe('transferHints', () => {
const both: TransferOptions = { showTurnOffHere: true, showTurnOn: true };
const needsSetUp = {
nodeTypesChecked: true,
needsSetUp: [{ name: 'Gmail', type: 'gmailApi', status: 'needs-set-up' as const }],
};
const ready = { nodeTypesChecked: true, needsSetUp: [] };
const unchecked = { nodeTypesChecked: false, needsSetUp: [] };
const choice = (turnOffHere: boolean, turnOn: boolean): TransferChoice => ({
turnOffHere,
turnOn,
});
it('shows no hint before the user picks anything', () => {
expect(transferHints(needsSetUp, both, choice(false, false))).toEqual([]);
expect(transferHints(unchecked, both, choice(false, false))).toEqual([]);
});
it('says that the workflow turns off here when the move does not turn the copy on', () => {
expect(transferHints(ready, both, choice(true, false))).toEqual(['turnsOffHere']);
expect(transferHints(needsSetUp, both, choice(true, false))).toEqual(['turnsOffHere']);
expect(transferHints(unchecked, both, choice(true, false))).toEqual(['turnsOffHere']);
});
it('says nothing more when the copy turns on and the check found nothing', () => {
expect(transferHints(ready, both, choice(true, true))).toEqual([]);
expect(transferHints(ready, both, choice(false, true))).toEqual([]);
});
it('says that this version cannot go live until its credentials are set up', () => {
expect(transferHints(needsSetUp, both, choice(false, true))).toEqual(['notLiveUntilSetUp']);
});
it('also says that the workflow here stays on when the user asked to turn it off', () => {
expect(transferHints(needsSetUp, both, choice(true, true))).toEqual([
'notLiveUntilSetUp',
'staysOnHere',
]);
});
it('says that the move checks the node types only when the copy is to turn on', () => {
expect(transferHints(unchecked, both, choice(false, true))).toEqual(['nodeTypesUnchecked']);
expect(transferHints(unchecked, both, choice(true, true))).toEqual([
'nodeTypesUncheckedStaysOnHere',
]);
});
it('names the empty credentials before the unchecked node types', () => {
const emptyAndUnchecked = { ...needsSetUp, nodeTypesChecked: false };
expect(transferHints(emptyAndUnchecked, both, choice(false, true))).toEqual([
'notLiveUntilSetUp',
]);
});
it('treats a credential that was not checked as no reason to stay off', () => {
const unknown = {
nodeTypesChecked: true,
needsSetUp: [{ name: 'Notion', type: 'notionApi', status: 'unknown' as const }],
};
expect(transferHints(unknown, both, choice(false, true))).toEqual([]);
});
it('ignores a choice that the dialog does not offer', () => {
const onlyTurnOn: TransferOptions = { showTurnOffHere: false, showTurnOn: true };
const onlyTurnOff: TransferOptions = { showTurnOffHere: true, showTurnOn: false };
const none: TransferOptions = { showTurnOffHere: false, showTurnOn: false };
expect(transferHints(needsSetUp, onlyTurnOn, choice(true, true))).toEqual([
'notLiveUntilSetUp',
]);
expect(transferHints(unchecked, onlyTurnOn, choice(true, true))).toEqual([
'nodeTypesUnchecked',
]);
expect(transferHints(needsSetUp, onlyTurnOff, choice(true, true))).toEqual(['turnsOffHere']);
expect(transferHints(needsSetUp, none, choice(true, true))).toEqual([]);
});
});
describe('transferRequest', () => {
it('asks for each offered choice that the user picked', () => {
expect(
transferRequest(
'wf-1',
{ showTurnOffHere: true, showTurnOn: true },
{ turnOffHere: true, turnOn: true },
),
).toEqual({ workflowId: 'wf-1', publish: true, deactivateLocal: true });
});
it('asks for nothing that the user did not pick', () => {
expect(
transferRequest(
'wf-1',
{ showTurnOffHere: true, showTurnOn: true },
{ turnOffHere: false, turnOn: false },
),
).toEqual({ workflowId: 'wf-1', publish: false, deactivateLocal: false });
});
it('never asks for a choice that the dialog did not offer', () => {
expect(
transferRequest(
'wf-1',
{ showTurnOffHere: false, showTurnOn: false },
{ turnOffHere: true, turnOn: true },
),
).toEqual({ workflowId: 'wf-1', publish: false, deactivateLocal: false });
});
});
describe('transferStatus', () => {
const state = (overrides: Parameters<typeof transferPreflight>[0] = {}) =>
transferDialogState(transferPreflight(overrides));
it('says that the check runs', () => {
expect(transferStatus('checking', 'idle')).toEqual({ kind: 'checking' });
});
it('counts the nodes and the credentials to set up when the check is done', () => {
expect(
transferStatus(
state({
moves: { nodes: 6 },
credentials: [
credential('Slack', 'matched'),
credential('Gmail', 'needs-set-up'),
credential('Notion', 'unknown'),
],
}),
'idle',
),
).toEqual({ kind: 'ready', nodeCount: 6, needsSetUp: 2 });
});
it('says that the workflow cannot move when something is under "Can\'t move"', () => {
expect(transferStatus(state({ missingNodeTypes: ['a@1'] }), 'idle')).toEqual({
kind: 'cannotMove',
});
expect(
transferStatus(state({ subWorkflowCalls: [{ id: 'wf-2', name: null }] }), 'idle'),
).toEqual({ kind: 'cannotMove' });
});
it('says that the move runs, whatever the check said', () => {
expect(transferStatus(state(), 'moving')).toEqual({ kind: 'moving' });
});
it('says nothing when an error notice reads out the problem', () => {
expect(transferStatus('failed', 'idle')).toEqual({ kind: 'silent' });
expect(transferStatus(state(), 'failed')).toEqual({ kind: 'silent' });
});
});

View File

@ -0,0 +1,221 @@
import fc from 'fast-check';
import { pushResult } from '../../__tests__/linkedInstances.fixtures';
import {
remoteCopyState,
remoteCredentialUrl,
safeHttpUrl,
transferResultView,
} from '../transferResult';
const BASE_URL = 'https://acme.app.n8n.cloud';
describe('safeHttpUrl', () => {
it.each([
['https://acme.app.n8n.cloud/workflow/1', 'https://acme.app.n8n.cloud/workflow/1'],
['http://localhost:5680/workflow/1', 'http://localhost:5680/workflow/1'],
['HTTPS://Acme.Example.Test', 'https://acme.example.test/'],
])('keeps the http(s) URL %s', (value, expected) => {
expect(safeHttpUrl(value)).toBe(expected);
});
it.each([
['another scheme', 'ftp://acme.example.test/workflow/1'],
['a data URL', 'data:text/html,hello'],
['a relative path', '/workflow/1'],
['an empty text', ''],
['text that is no URL', 'not a url'],
])('refuses %s', (_label, value) => {
expect(safeHttpUrl(value)).toBeUndefined();
});
});
describe('remoteCredentialUrl', () => {
it('opens the credential in the editor of the linked instance', () => {
expect(remoteCredentialUrl(BASE_URL, 'cred-1')).toBe(
'https://acme.app.n8n.cloud/home/credentials/cred-1',
);
});
it('keeps a path prefix and drops trailing slashes of the address', () => {
expect(remoteCredentialUrl('https://acme.example.test/n8n//', 'cred-1')).toBe(
'https://acme.example.test/n8n/home/credentials/cred-1',
);
});
it('encodes the credential id', () => {
expect(remoteCredentialUrl(BASE_URL, 'a/b?c')).toBe(
'https://acme.app.n8n.cloud/home/credentials/a%2Fb%3Fc',
);
});
it('gives no URL for an address that is not http(s)', () => {
expect(remoteCredentialUrl('ftp://acme.example.test', 'cred-1')).toBeUndefined();
});
});
describe('remoteCopyState', () => {
it.each([
[{ published: true, publishFailed: false }, 'live'],
[{ published: true, publishFailed: true }, 'earlierLive'],
[{ published: false, publishFailed: true }, 'notLive'],
[{ published: false, publishFailed: false }, 'notLive'],
] as const)('reads %o of a move that asked to publish as %s', (fields, expected) => {
expect(remoteCopyState(fields, true)).toBe(expected);
});
it.each([
[{ published: true, publishFailed: false }, 'keptLive'],
[{ published: false, publishFailed: false }, 'notLive'],
] as const)('reads %o of a move that did not ask to publish as %s', (fields, expected) => {
expect(remoteCopyState(fields, false)).toBe(expected);
});
it('does not say that the moved version is live when only an earlier version is', () => {
// A second move of a copy that is live there, from a workflow that is off here.
const result = pushResult({
created: false,
published: true,
publishFailed: false,
warnings: [
'The new version is not live, because the source workflow does not publish this version. An earlier version stays live.',
],
});
expect(remoteCopyState(result, false)).toBe('keptLive');
});
});
describe('transferResultView', () => {
it('is a success when the copy needs nothing', () => {
const view = transferResultView(pushResult({ published: true }), BASE_URL, true);
expect(view).toEqual({
tone: 'success',
remoteState: 'live',
turnedOffHere: false,
openUrl: 'https://acme.app.n8n.cloud/workflow/remote-wf-1',
needsSetUp: [],
missingNodeTypes: [],
warnings: [],
});
});
it.each([
['the publish failed', { publishFailed: true }],
['a credential needs set-up', { credentialsNeedingSetup: [{ id: 'c', name: 'G', type: 'g' }] }],
['a node type is missing', { missingNodeTypes: ['acme.thing@1'] }],
['the server sent a warning', { warnings: ['The workflow went to your personal project.'] }],
])('is a warning when %s', (_label, overrides) => {
expect(transferResultView(pushResult(overrides), BASE_URL, true).tone).toBe('warning');
});
it('links each credential that needs set-up to the linked instance', () => {
const view = transferResultView(
pushResult({
credentialsNeedingSetup: [
{ id: 'cred-1', name: 'Gmail account', type: 'gmailOAuth2' },
{ id: 'cred-2', name: 'Notion', type: 'notionApi' },
],
}),
BASE_URL,
false,
);
expect(view.needsSetUp.map(({ name, url }) => ({ name, url }))).toEqual([
{ name: 'Gmail account', url: 'https://acme.app.n8n.cloud/home/credentials/cred-1' },
{ name: 'Notion', url: 'https://acme.app.n8n.cloud/home/credentials/cred-2' },
]);
});
it('gives no link for a copy URL that is not http(s)', () => {
const view = transferResultView(
pushResult({ remoteUrl: 'ftp://acme.example.test/workflow/1' }),
BASE_URL,
false,
);
expect(view.openUrl).toBeUndefined();
});
it('tells when the workflow here was turned off', () => {
expect(
transferResultView(pushResult({ localDeactivated: true }), BASE_URL, false).turnedOffHere,
).toBe(true);
});
});
describe('transferResultView (property)', () => {
const credentialArb = fc.record({ id: fc.string(), name: fc.string(), type: fc.string() });
const resultArb = fc.record({
remoteWorkflowId: fc.string(),
remoteUrl: fc.oneof(fc.webUrl(), fc.string()),
targetProject: fc.constant(null),
created: fc.boolean(),
published: fc.boolean(),
publishFailed: fc.boolean(),
credentialsNeedingSetup: fc.array(credentialArb, { maxLength: 4 }),
missingNodeTypes: fc.array(fc.string(), { maxLength: 4 }),
localDeactivated: fc.boolean(),
warnings: fc.array(fc.string(), { maxLength: 4 }),
});
const baseUrlArb = fc.oneof(fc.webUrl(), fc.string());
it('links only to http(s) addresses', () => {
fc.assert(
fc.property(resultArb, baseUrlArb, fc.boolean(), (result, baseUrl, publishAsked) => {
const view = transferResultView(result, baseUrl, publishAsked);
const urls = [view.openUrl, ...view.needsSetUp.map(({ url }) => url)];
for (const url of urls) {
if (url !== undefined) expect(new URL(url).protocol).toMatch(/^https?:$/);
}
}),
);
});
it('is a success exactly when nothing needs the user', () => {
fc.assert(
fc.property(resultArb, fc.boolean(), (result, publishAsked) => {
const quiet =
!result.publishFailed &&
result.credentialsNeedingSetup.length === 0 &&
result.missingNodeTypes.length === 0 &&
result.warnings.length === 0;
expect(transferResultView(result, BASE_URL, publishAsked).tone).toBe(
quiet ? 'success' : 'warning',
);
}),
);
});
it('says that the moved version is live only when the move asked to publish and it worked', () => {
fc.assert(
fc.property(resultArb, fc.boolean(), (result, publishAsked) => {
const state = transferResultView(result, BASE_URL, publishAsked).remoteState;
expect(state === 'notLive').toBe(!result.published);
expect(state === 'live').toBe(result.published && publishAsked && !result.publishFailed);
expect(state === 'earlierLive').toBe(
result.published && publishAsked && result.publishFailed,
);
expect(state === 'keptLive').toBe(result.published && !publishAsked);
}),
);
});
it('keeps every credential, node type and warning as given', () => {
fc.assert(
fc.property(resultArb, fc.boolean(), (result, publishAsked) => {
const view = transferResultView(result, BASE_URL, publishAsked);
expect(view.needsSetUp.map(({ id, name, type }) => ({ id, name, type }))).toEqual(
result.credentialsNeedingSetup,
);
expect(view.missingNodeTypes).toEqual(result.missingNodeTypes);
expect(view.warnings).toEqual(result.warnings);
}),
);
});
});

View File

@ -0,0 +1,10 @@
// Text for screen readers only. N8nVisuallyHidden is no replacement: it sets aria-hidden, so
// screen readers skip its text too.
@mixin visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

View File

@ -0,0 +1,32 @@
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
/**
* Shows here that the move turned off the workflow. The server tells the other open editors, but
* not this tab, because this tab holds the write lock. The turn-off also changes the checksum of
* the workflow, so the open editor reads it again: the next save would fail with the old one.
*/
export async function syncLocalTurnOff(workflowId: string): Promise<void> {
const workflowsStore = useWorkflowsStore();
const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
workflowsStore.setWorkflowInactive(workflowId);
documentStore.setActiveState({ activeVersionId: null, activeVersion: null });
if (workflowsStore.workflowId !== workflowId) return;
try {
const saved = await useWorkflowsListStore().fetchWorkflow(workflowId);
documentStore.setChecksum(saved.checksum ?? '');
documentStore.setVersionData({
versionId: saved.versionId,
name: documentStore.versionData?.name ?? null,
description: documentStore.versionData?.description ?? null,
});
} catch {
// The move and the turn-off worked. A save with the old checksum shows the conflict
// dialog, which reloads the workflow.
}
}

View File

@ -0,0 +1,74 @@
import {
LINKED_INSTANCE_TRANSFER_CREDENTIAL_STATUSES,
type LinkedInstancePushResult,
type LinkedInstanceTransferPreflight,
type LinkedInstanceTransferPreflightRequestDto,
type LinkedInstanceTransferRequestDto,
} from '@n8n/api-types';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { z } from 'zod';
import { instancePath } from '../linkedInstances.api';
const remoteProjectSchema = z.object({ id: z.string(), name: z.string() });
// `z.object` drops the keys that it does not name, so no other field of a response reaches the UI.
const preflightSchema = z.object({
workflowName: z.string(),
moves: z.object({ nodes: z.number().int().nonnegative() }),
// A newer server can send a value that this client does not know yet.
nodeTypeCheck: z.enum(['checked', 'unknown']).catch('unknown'),
missingNodeTypes: z.array(z.string()),
credentials: z.array(
z.object({
name: z.string(),
type: z.string(),
status: z.enum(LINKED_INSTANCE_TRANSFER_CREDENTIAL_STATUSES).catch('unknown'),
}),
),
targetProject: remoteProjectSchema.nullable(),
subWorkflowCalls: z.array(z.object({ id: z.string(), name: z.string().nullable() })),
}) satisfies z.ZodType<LinkedInstanceTransferPreflight, z.ZodTypeDef, unknown>;
// The URLs stay plain strings here: the move worked, so a strange URL must not turn it into an
// error. The view accepts only http(s) URLs for a link.
const pushResultSchema = z.object({
remoteWorkflowId: z.string(),
remoteUrl: z.string(),
targetProject: remoteProjectSchema.nullable(),
created: z.boolean(),
published: z.boolean(),
publishFailed: z.boolean(),
credentialsNeedingSetup: z.array(
z.object({ id: z.string(), name: z.string(), type: z.string() }),
),
missingNodeTypes: z.array(z.string()),
localDeactivated: z.boolean(),
warnings: z.array(z.string()),
}) satisfies z.ZodType<LinkedInstancePushResult, z.ZodTypeDef, unknown>;
/** Tells what a move of the workflow to the linked instance would do. Changes nothing. */
export async function fetchTransferPreflight(
context: IRestApiContext,
linkId: string,
payload: LinkedInstanceTransferPreflightRequestDto,
): Promise<LinkedInstanceTransferPreflight> {
const endpoint = `${instancePath(linkId)}/transfer/preflight`;
const response: unknown = await makeRestApiRequest(context, 'POST', endpoint, payload);
return preflightSchema.parse(response);
}
/**
* Copies the workflow to the linked instance. A repeated move updates the same copy. The request
* sends the push-ref of this tab, so the server can turn off a workflow that this tab edits.
*/
export async function moveWorkflow(
context: IRestApiContext,
linkId: string,
payload: LinkedInstanceTransferRequestDto,
): Promise<LinkedInstancePushResult> {
const endpoint = `${instancePath(linkId)}/transfer`;
const response: unknown = await makeRestApiRequest(context, 'POST', endpoint, payload);
return pushResultSchema.parse(response);
}

View File

@ -0,0 +1,166 @@
import type {
LinkedInstanceTransferCredential,
LinkedInstanceTransferPreflight,
LinkedInstanceTransferRequestDto,
LinkedInstanceTransferSubWorkflow,
} from '@n8n/api-types';
/** The workflow here, as the opener of the dialog sees it. */
export interface TransferWorkflow {
id: string;
name: string;
/** A version of the workflow is live here. */
liveHere: boolean;
/** The user can turn off the workflow here (`workflow:unpublish`). */
canUnpublish: boolean;
}
/** A credential that the user must look at in the linked instance after the move. */
export type TransferSetUpItem = Omit<LinkedInstanceTransferCredential, 'status'> & {
/** `needs-set-up`: the move creates it empty. `unknown`: the linked instance did not tell. */
status: Exclude<LinkedInstanceTransferCredential['status'], 'matched'>;
};
/** Something that the user should know before the move, although the move can go ahead. */
export type TransferWarning = 'credentialsUnchecked';
/** What the dialog shows for one preflight. */
export interface TransferDialogState {
nodeCount: number;
/** `null`: the personal project of the access token's user in the linked instance. */
targetProjectName: string | null;
/** Names of the credentials that the copy uses there. They can still be empty. */
matchedCredentials: string[];
/** "Needs setting up", in the order of the preflight. */
needsSetUp: TransferSetUpItem[];
/** "Can't move": node types that the linked instance does not have, as "type@version". */
missingNodeTypes: string[];
/** "Can't move": a move copies one workflow only, so it cannot take the workflows that this one calls. */
subWorkflowCalls: LinkedInstanceTransferSubWorkflow[];
/** `false` while anything is listed under "Can't move". */
canMove: boolean;
/** `false`: the linked instance lists no node types, so only the move can find a missing one. */
nodeTypesChecked: boolean;
warnings: TransferWarning[];
}
/** Builds the sections of the dialog from a preflight. */
export function transferDialogState(
preflight: LinkedInstanceTransferPreflight,
): TransferDialogState {
const matchedCredentials: string[] = [];
const needsSetUp: TransferSetUpItem[] = [];
for (const { name, type, status } of preflight.credentials) {
if (status === 'matched') matchedCredentials.push(name);
else needsSetUp.push({ name, type, status });
}
const warnings: TransferWarning[] = [];
if (needsSetUp.some(({ status }) => status === 'unknown')) warnings.push('credentialsUnchecked');
return {
nodeCount: preflight.moves.nodes,
targetProjectName: preflight.targetProject?.name ?? null,
matchedCredentials,
needsSetUp,
missingNodeTypes: [...preflight.missingNodeTypes],
subWorkflowCalls: [...preflight.subWorkflowCalls],
canMove: preflight.missingNodeTypes.length === 0 && preflight.subWorkflowCalls.length === 0,
nodeTypesChecked: preflight.nodeTypeCheck === 'checked',
warnings,
};
}
/** What the caller knows about the workflow here and about the request of the user. */
export interface TransferOptionsInput {
/** A version of the workflow is live here. */
liveHere: boolean;
/** The user can turn off the workflow here (`workflow:unpublish`). */
canUnpublish: boolean;
/** The user asked for a live automation, so the dialog offers to turn the copy on. */
offerTurnOn: boolean;
}
/** The choices that the dialog offers. Both choices start off. */
export interface TransferOptions {
/** "The copy here": only for a live workflow that the user can turn off. */
showTurnOffHere: boolean;
/** "Turn it on in {place}": only when the user asked for a live automation. */
showTurnOn: boolean;
}
export function transferOptions(input: TransferOptionsInput): TransferOptions {
return {
showTurnOffHere: input.liveHere && input.canUnpublish,
showTurnOn: input.offerTurnOn,
};
}
/** What the user picked. A choice that the dialog does not show has no effect. */
export interface TransferChoice {
turnOffHere: boolean;
turnOn: boolean;
}
/** A line under the choices that tells what the picked choices do. */
export type TransferHint =
| 'turnsOffHere'
| 'notLiveUntilSetUp'
| 'staysOnHere'
| 'nodeTypesUnchecked'
| 'nodeTypesUncheckedStaysOnHere';
export function transferHints(
state: Pick<TransferDialogState, 'needsSetUp' | 'nodeTypesChecked'>,
options: TransferOptions,
choice: TransferChoice,
): TransferHint[] {
const turnOffHere = options.showTurnOffHere && choice.turnOffHere;
const turnOn = options.showTurnOn && choice.turnOn;
// Without a publish the copy there keeps its state, which the preflight does not know.
if (!turnOn) return turnOffHere ? ['turnsOffHere'] : [];
// The move does not publish a copy that has empty credentials. The server then keeps the
// workflow here on.
if (state.needsSetUp.some(({ status }) => status === 'needs-set-up')) {
return turnOffHere ? ['notLiveUntilSetUp', 'staysOnHere'] : ['notLiveUntilSetUp'];
}
if (state.nodeTypesChecked) return [];
// A missing node type blocks the publish in the same way, but only the move can find it.
return [turnOffHere ? 'nodeTypesUncheckedStaysOnHere' : 'nodeTypesUnchecked'];
}
/** The request of the move. It never asks for a choice that the dialog did not offer. */
export function transferRequest(
workflowId: string,
options: TransferOptions,
choice: TransferChoice,
): LinkedInstanceTransferRequestDto {
return {
workflowId,
publish: options.showTurnOn && choice.turnOn,
deactivateLocal: options.showTurnOffHere && choice.turnOffHere,
};
}
/** The preflight as the dialog sees it: still running, failed, or its sections. */
export type TransferCheck = TransferDialogState | 'checking' | 'failed';
export type TransferMoveStatus = 'idle' | 'moving' | 'failed';
/** What the status line of the dialog tells screen reader users. */
export type TransferStatus =
| { kind: 'silent' }
| { kind: 'checking' }
| { kind: 'cannotMove' }
| { kind: 'ready'; nodeCount: number; needsSetUp: number }
| { kind: 'moving' };
export function transferStatus(check: TransferCheck, move: TransferMoveStatus): TransferStatus {
if (move === 'moving') return { kind: 'moving' };
// A notice with role="alert" reads out each error. The status line then says nothing more.
if (move === 'failed' || check === 'failed') return { kind: 'silent' };
if (check === 'checking') return { kind: 'checking' };
if (!check.canMove) return { kind: 'cannotMove' };
return { kind: 'ready', nodeCount: check.nodeCount, needsSetUp: check.needsSetUp.length };
}

View File

@ -0,0 +1,86 @@
import type {
LinkedInstanceCredentialNeedingSetup,
LinkedInstancePushResult,
} from '@n8n/api-types';
/**
* The state of the copy in the linked instance after the move. `keptLive`: the copy was live
* there before the move, and a version of it stays live. It can be the new or an earlier version.
*/
export type RemoteCopyState = 'live' | 'earlierLive' | 'keptLive' | 'notLive';
export type TransferSetUpLink = LinkedInstanceCredentialNeedingSetup & {
/** Opens the credential in the linked instance. `undefined` when its address is not http(s). */
url: string | undefined;
};
/** What the message after a move shows. Text from the linked instance stays plain text. */
export interface TransferResultView {
/** `warning` when something needs the user: set-up, missing node types or a warning. */
tone: 'success' | 'warning';
remoteState: RemoteCopyState;
turnedOffHere: boolean;
/** Opens the copy in the linked instance. `undefined` when the address is not http(s). */
openUrl: string | undefined;
needsSetUp: TransferSetUpLink[];
missingNodeTypes: string[];
warnings: string[];
}
/** The URL as the browser reads it, or `undefined` for any scheme but http and https. */
export function safeHttpUrl(value: string): string | undefined {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined;
} catch {
return undefined;
}
}
/** Opens a credential in the n8n editor at `baseUrl`, as `/home/credentials/<id>` does here. */
export function remoteCredentialUrl(baseUrl: string, credentialId: string): string | undefined {
const base = safeHttpUrl(baseUrl);
if (base === undefined) return undefined;
return `${base.replace(/\/+$/, '')}/home/credentials/${encodeURIComponent(credentialId)}`;
}
/**
* The fields tell the state, never the warning texts. `publishFailed` with `published` means that
* an earlier version stays live there. `publishFailed` is `false` when the move did not ask to
* publish, so then the result does not tell which version is live. The warnings of the linked
* instance say it.
*/
export function remoteCopyState(
result: Pick<LinkedInstancePushResult, 'published' | 'publishFailed'>,
publishAsked: boolean,
): RemoteCopyState {
if (!result.published) return 'notLive';
if (!publishAsked) return 'keptLive';
return result.publishFailed ? 'earlierLive' : 'live';
}
/** `publishAsked`: the `publish` field of the request of the move. */
export function transferResultView(
result: LinkedInstancePushResult,
baseUrl: string,
publishAsked: boolean,
): TransferResultView {
const needsAttention =
result.publishFailed ||
result.credentialsNeedingSetup.length > 0 ||
result.missingNodeTypes.length > 0 ||
result.warnings.length > 0;
return {
tone: needsAttention ? 'warning' : 'success',
remoteState: remoteCopyState(result, publishAsked),
turnedOffHere: result.localDeactivated,
openUrl: safeHttpUrl(result.remoteUrl),
needsSetUp: result.credentialsNeedingSetup.map((credential) => ({
...credential,
url: remoteCredentialUrl(baseUrl, credential.id),
})),
missingNodeTypes: [...result.missingNodeTypes],
warnings: [...result.warnings],
};
}

View File

@ -0,0 +1,169 @@
import type { LinkedInstanceSummary } from '@n8n/api-types';
import type { DropdownMenuExposed, DropdownMenuItemProps } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, useTemplateRef, watch, type Ref } from 'vue';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { fetchLinkedInstances } from '../linkedInstances.api';
import { LINKED_INSTANCES_MODULE_ID, LINKED_INSTANCES_SCOPE } from '../linkedInstances.constants';
import type { TransferWorkflow } from './transferDialogState';
import { useSaveBeforeMove } from './useSaveBeforeMove';
/** Menu ids of the moves. No dependency type and no fixed action of the menu starts like this. */
export const MOVE_MENU_ID_PREFIX = 'move-to-linked-instance:';
/** The fields of the workflow header menu that decide whether a workflow can move. */
export interface MoveMenuWorkflow {
id: string;
name: string;
isNewWorkflow: boolean;
isArchived?: boolean;
workflowPermissions: PermissionsRecord['workflow'];
}
/**
* The props and listeners of the move dialog, bound with `v-bind` next to the menu. The menu does
* not offer to turn on the copy there. Only a request for a live automation does, for example from
* the Assistant.
*/
export interface MoveDialogBindings {
open: boolean;
workflow: TransferWorkflow;
instance: LinkedInstanceSummary;
'onUpdate:open': (open: boolean) => void;
onClosed: () => void;
}
/**
* The links of the user. Only the latest read counts. A failed read keeps the last list, because
* the dialog checks the link again before anything moves. Without any list, the menu shows no move.
*/
function useMoveLinks(available: Readonly<Ref<boolean>>) {
const rootStore = useRootStore();
const links = ref<LinkedInstanceSummary[]>([]);
let reads = 0;
async function load(): Promise<void> {
const current = ++reads;
try {
const list = await fetchLinkedInstances(rootStore.restApiContext);
if (current === reads) links.value = list;
} catch {
// The menu works without the moves.
}
}
// The menu shows the moves on the first open, so the list loads before that.
watch(
available,
(on) => {
if (on) void load();
},
{ immediate: true },
);
return { links, load };
}
/** The bindings of the move dialog. Its focus goes back to the menu after it closes. */
function useMoveDialog(workflow: MoveMenuWorkflow, focusMenu: () => void) {
const documentStore = injectWorkflowDocumentStore();
const dialog = ref<MoveDialogBindings>();
function open(instance: LinkedInstanceSummary): void {
dialog.value = {
open: false,
workflow: {
id: workflow.id,
name: workflow.name,
liveHere: documentStore.value.active,
canUnpublish: workflow.workflowPermissions.unpublish === true,
},
instance,
'onUpdate:open': (isOpen) => {
if (dialog.value) dialog.value.open = isOpen;
},
onClosed: focusMenu,
};
// Open after the menu has left the page, so the two layers do not fight for focus.
requestAnimationFrame(() => {
if (dialog.value?.instance.id === instance.id) dialog.value.open = true;
});
}
return { dialog, open };
}
/**
* The "Move to {name}" entries of the workflow header menu, one for each link. The server needs
* the linked-instances module, the scope of its routes and the right to export the workflow. A new
* or archived workflow cannot move.
*/
export function useMoveWorkflowMenu(
workflow: MoveMenuWorkflow,
/** The template ref of the menu in the host. Focus goes back to its trigger. */
menuRef: string,
) {
const menu = useTemplateRef<DropdownMenuExposed>(menuRef);
const i18n = useI18n();
const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
const { ensureSaved } = useSaveBeforeMove();
const available = computed(
() =>
settingsStore.isModuleActive(LINKED_INSTANCES_MODULE_ID) &&
rbacStore.hasScope(LINKED_INSTANCES_SCOPE),
);
const canMove = computed(
() =>
available.value &&
!workflow.isNewWorkflow &&
workflow.isArchived !== true &&
workflow.workflowPermissions.export === true,
);
const focusMenu = () => menu.value?.focusTrigger();
const { links, load } = useMoveLinks(available);
const { dialog, open: openDialog } = useMoveDialog(workflow, focusMenu);
const menuItems = computed<Array<DropdownMenuItemProps<string>>>(() =>
canMove.value
? links.value.map((link) => ({
id: `${MOVE_MENU_ID_PREFIX}${link.id}`,
label: i18n.baseText('linkedInstances.transfer.menu.moveTo', {
interpolate: { name: link.name },
}),
icon: { type: 'icon', value: 'send' },
// The dialog opens after the menu closes and puts focus back itself.
suppressCloseAutoFocus: true,
}))
: [],
);
/** A link can change in another tab, so the list loads again on each open of the menu. */
function onMenuToggle(isOpen: boolean): void {
if (isOpen && available.value) void load();
}
async function startMove(instance: LinkedInstanceSummary): Promise<void> {
if (await ensureSaved(instance.name)) openDialog(instance);
else focusMenu();
}
/** @returns true when the action is a move. The move then starts. */
function select(action: string): boolean {
if (!action.startsWith(MOVE_MENU_ID_PREFIX)) return false;
const id = action.slice(MOVE_MENU_ID_PREFIX.length);
const instance = links.value.find((link) => link.id === id);
if (instance && canMove.value) void startMove(instance);
return true;
}
return { menuItems, onMenuToggle, select, dialog };
}

View File

@ -0,0 +1,56 @@
import { useMessage } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { AutoSaveState, MODAL_CONFIRM } from '@/app/constants';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowSaveStore } from '@/app/stores/workflowSave.store';
/**
* A move exports the saved workflow, so the editor saves its changes first. The user decides:
* a save can also change what other people see.
*/
export function useSaveBeforeMove() {
const i18n = useI18n();
const message = useMessage();
const uiStore = useUIStore();
const saveStore = useWorkflowSaveStore();
const { saveCurrentWorkflow, cancelAutoSave } = useWorkflowSaving({ router: useRouter() });
async function confirmSave(place: string): Promise<boolean> {
const choice = await message.confirm(
i18n.baseText('linkedInstances.transfer.unsaved.message', { interpolate: { place } }),
i18n.baseText('linkedInstances.transfer.unsaved.title'),
{
type: 'warning',
confirmButtonText: i18n.baseText('linkedInstances.transfer.unsaved.confirm'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
return choice === MODAL_CONFIRM;
}
/** An autosave that runs now can hold every change, so it ends first. */
async function settleAutoSave(): Promise<void> {
if (saveStore.autoSaveState === AutoSaveState.InProgress && saveStore.pendingSave) {
// A failed autosave leaves the editor dirty, so the check below still sees the changes.
await saveStore.pendingSave.catch(() => false);
}
}
/**
* Asks to save when the editor has changes, then saves them.
* @param place the name of the linked instance, for the question
* @returns true when the saved workflow holds every change
*/
async function ensureSaved(place: string): Promise<boolean> {
await settleAutoSave();
if (!uiStore.stateIsDirty) return true;
if (!(await confirmSave(place))) return false;
if (saveStore.autoSaveState === AutoSaveState.Scheduled) cancelAutoSave();
return await saveCurrentWorkflow({}, false);
}
return { ensureSaved };
}

View File

@ -0,0 +1,98 @@
import type {
LinkedInstancePushResult,
LinkedInstanceTransferPreflight,
LinkedInstanceTransferRequestDto,
} from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { ref, watch, type WatchSource } from 'vue';
import { requestErrorMessage } from '../composables/useTokenRequest';
import { fetchTransferPreflight, moveWorkflow } from './transfer.api';
export type PreflightState =
| { status: 'checking' }
| { status: 'ready'; preflight: LinkedInstanceTransferPreflight }
| { status: 'failed'; message: string };
/** One opening of the dialog: the link and the workflow that it moves. */
export interface TransferSubject {
linkId: string;
workflowId: string;
}
interface TransferMessages {
checkFailed: () => string;
moveFailed: () => string;
}
/**
* The requests of the move dialog. The preflight runs each time the dialog opens. A request that
* ends after the dialog closed, or after it opened again, changes nothing.
*/
export function useTransferWorkflow(
open: WatchSource<boolean>,
subject: () => TransferSubject,
messages: TransferMessages,
) {
const rootStore = useRootStore();
const preflight = ref<PreflightState>({ status: 'checking' });
const isMoving = ref(false);
const moveError = ref<string>();
// Changes on every open, close and check. A request reports back only while its turn is current.
let turn = 0;
async function check(): Promise<void> {
const current = ++turn;
preflight.value = { status: 'checking' };
moveError.value = undefined;
const { linkId, workflowId } = subject();
try {
const result = await fetchTransferPreflight(rootStore.restApiContext, linkId, {
workflowId,
});
if (current === turn) preflight.value = { status: 'ready', preflight: result };
} catch (error) {
if (current !== turn) return;
preflight.value = {
status: 'failed',
message: requestErrorMessage(error, messages.checkFailed()),
};
}
}
/** @returns the result, or `undefined` when the move failed or the dialog closed meanwhile */
async function move(
request: LinkedInstanceTransferRequestDto,
): Promise<LinkedInstancePushResult | undefined> {
if (isMoving.value || preflight.value.status !== 'ready') return undefined;
const current = turn;
isMoving.value = true;
moveError.value = undefined;
try {
const result = await moveWorkflow(rootStore.restApiContext, subject().linkId, request);
return current === turn ? result : undefined;
} catch (error) {
if (current === turn) moveError.value = requestErrorMessage(error, messages.moveFailed());
return undefined;
} finally {
if (current === turn) isMoving.value = false;
}
}
watch(
open,
(isOpen) => {
isMoving.value = false;
if (isOpen) {
void check();
return;
}
turn += 1;
moveError.value = undefined;
},
{ immediate: true },
);
return { preflight, isMoving, moveError, retry: check, move };
}