diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 711326181ae..a9d8dc58d3d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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.", diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue index 3bf7a6bee7b..a4c895c7623 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue @@ -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 (`:`) share the menu with the fixed actions. type WorkflowMenuItem = DropdownMenuItemProps; @@ -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(() => { }); } + 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 { + if (moveMenu.select(action)) return; const dependency = resolveDependencyMenuId( getDependencies(props.id, 'workflow')?.dependencies ?? [], action, @@ -617,6 +624,7 @@ defineExpose({ + diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferPreflightSummary.vue b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferPreflightSummary.vue new file mode 100644 index 00000000000..906c88f42e1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferPreflightSummary.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferResultMessage.vue b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferResultMessage.vue new file mode 100644 index 00000000000..8afbb789f34 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferResultMessage.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferStatusLine.vue b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferStatusLine.vue new file mode 100644 index 00000000000..cad567a5516 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferStatusLine.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferWorkflowDialog.vue b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferWorkflowDialog.vue new file mode 100644 index 00000000000..91860e95133 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/TransferWorkflowDialog.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferResultMessage.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferResultMessage.test.ts new file mode 100644 index 00000000000..323962ef5d5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferResultMessage.test.ts @@ -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, 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 team 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'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferWorkflowDialog.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferWorkflowDialog.test.ts new file mode 100644 index 00000000000..7dd7d7663f6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/TransferWorkflowDialog.test.ts @@ -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(), + moveWorkflow: vi.fn(), +})); +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 => ({ + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/moveWorkflowMenu.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/moveWorkflowMenu.test.ts new file mode 100644 index 00000000000..4243ca3b8e3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/moveWorkflowMenu.test.ts @@ -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(), + instancePath: (id: string) => `/linked-instances/${id}`, +})); +vi.mock('@/features/linkedInstances/linkedInstances.api', () => linkApi); + +const transferApi = vi.hoisted(() => ({ + fetchTransferPreflight: vi.fn(), + moveWorkflow: vi.fn(), +})); +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('@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(); + 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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/syncLocalTurnOff.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/syncLocalTurnOff.test.ts new file mode 100644 index 00000000000..9bbab5ad648 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/syncLocalTurnOff.test.ts @@ -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'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transfer.api.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transfer.api.test.ts new file mode 100644 index 00000000000..ef2190bdf40 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transfer.api.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.property.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.property.test.ts new file mode 100644 index 00000000000..dab30f0e784 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.property.test.ts @@ -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 = 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('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, + ); + } + }), + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.test.ts new file mode 100644 index 00000000000..4a276021026 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferDialogState.test.ts @@ -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[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' }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferResult.test.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferResult.test.ts new file mode 100644 index 00000000000..db6eed636f1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/__tests__/transferResult.test.ts @@ -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); + }), + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/_visually-hidden.scss b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/_visually-hidden.scss new file mode 100644 index 00000000000..3c754f635d9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/_visually-hidden.scss @@ -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; +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/syncLocalTurnOff.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/syncLocalTurnOff.ts new file mode 100644 index 00000000000..75562cf4bd5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/syncLocalTurnOff.ts @@ -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 { + 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. + } +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transfer.api.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transfer.api.ts new file mode 100644 index 00000000000..8fd28d5255c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transfer.api.ts @@ -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; + +// 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; + +/** 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 { + 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 { + const endpoint = `${instancePath(linkId)}/transfer`; + const response: unknown = await makeRestApiRequest(context, 'POST', endpoint, payload); + return pushResultSchema.parse(response); +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferDialogState.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferDialogState.ts new file mode 100644 index 00000000000..4a9fdf30480 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferDialogState.ts @@ -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 & { + /** `needs-set-up`: the move creates it empty. `unknown`: the linked instance did not tell. */ + status: Exclude; +}; + +/** 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, + 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 }; +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferResult.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferResult.ts new file mode 100644 index 00000000000..b17c13cb75a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/transferResult.ts @@ -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/` 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, + 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], + }; +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useMoveWorkflowMenu.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useMoveWorkflowMenu.ts new file mode 100644 index 00000000000..e000648acf8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useMoveWorkflowMenu.ts @@ -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>) { + const rootStore = useRootStore(); + const links = ref([]); + let reads = 0; + + async function load(): Promise { + 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(); + + 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(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>>(() => + 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 { + 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 }; +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useSaveBeforeMove.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useSaveBeforeMove.ts new file mode 100644 index 00000000000..7a5d6f2cbec --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useSaveBeforeMove.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useTransferWorkflow.ts b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useTransferWorkflow.ts new file mode 100644 index 00000000000..d13cceae734 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/linkedInstances/transfer/useTransferWorkflow.ts @@ -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, + subject: () => TransferSubject, + messages: TransferMessages, +) { + const rootStore = useRootStore(); + const preflight = ref({ status: 'checking' }); + const isMoving = ref(false); + const moveError = ref(); + + // Changes on every open, close and check. A request reports back only while its turn is current. + let turn = 0; + + async function check(): Promise { + 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 { + 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 }; +}