mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
6a60b3aa7f
commit
e0cd96be33
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user