mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Add MCP nudge modal on JSON export/import and copy/paste (#38144)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c95756df7b
commit
8a463dab3a
|
|
@ -37,6 +37,17 @@ describe('UserSelfSettingsUpdateRequestDto', () => {
|
|||
const data = {
|
||||
easyAIWorkflowOnboarded: false,
|
||||
dismissedCallouts: { 'test-callout': true },
|
||||
mcpJsonNudge: { impressions: 1 },
|
||||
};
|
||||
|
||||
const result = UserSelfSettingsUpdateRequestDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass validation with mcpJsonNudge', () => {
|
||||
const data = {
|
||||
mcpJsonNudge: { impressions: 2 },
|
||||
};
|
||||
|
||||
const result = UserSelfSettingsUpdateRequestDto.safeParse(data);
|
||||
|
|
@ -81,6 +92,42 @@ describe('UserSelfSettingsUpdateRequestDto', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['dismissedCallouts', 'some-callout']);
|
||||
});
|
||||
|
||||
it('should fail validation with invalid mcpJsonNudge type', () => {
|
||||
const data = {
|
||||
mcpJsonNudge: 'invalid',
|
||||
};
|
||||
|
||||
const result = UserSelfSettingsUpdateRequestDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path[0]).toBe('mcpJsonNudge');
|
||||
});
|
||||
|
||||
it('should fail validation with invalid mcpJsonNudge.impressions type', () => {
|
||||
const data = {
|
||||
mcpJsonNudge: { impressions: 'invalid' },
|
||||
};
|
||||
|
||||
const result = UserSelfSettingsUpdateRequestDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['mcpJsonNudge', 'impressions']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a negative count', -1],
|
||||
['a fractional count', 1.5],
|
||||
])('should fail validation with %s for mcpJsonNudge.impressions', (_label, impressions) => {
|
||||
const data = {
|
||||
mcpJsonNudge: { impressions },
|
||||
};
|
||||
|
||||
const result = UserSelfSettingsUpdateRequestDto.safeParse(data);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['mcpJsonNudge', 'impressions']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security: restricted fields should be stripped', () => {
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ import { Z } from '../../zod-class';
|
|||
export class UserSelfSettingsUpdateRequestDto extends Z.class({
|
||||
easyAIWorkflowOnboarded: z.boolean().optional(),
|
||||
dismissedCallouts: z.record(z.string(), z.boolean()).optional(),
|
||||
mcpJsonNudge: z.object({ impressions: z.number().int().nonnegative() }).optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,54 @@ const clientBrand = z
|
|||
|
||||
const clientType = z.enum(['cli', 'ide', 'editor', 'assistant']).nullable();
|
||||
|
||||
/** Which JSON action opened the MCP nudge modal. */
|
||||
const nudgeSurface = z
|
||||
.enum(['export', 'import_file', 'import_url', 'copy', 'paste'])
|
||||
.describe(
|
||||
'The action that opened the nudge: the Export JSON menu item, Import from file, Import from URL, copying nodes, or pasting workflow JSON onto the canvas',
|
||||
);
|
||||
|
||||
export const MCP_TELEMETRY = defineTelemetryEvents({
|
||||
MCP_NUDGE_VIEWED: {
|
||||
name: 'User viewed MCP nudge',
|
||||
description:
|
||||
'The MCP nudge modal opened after the user exported a workflow as JSON, imported one from a file or URL, copied nodes, or pasted workflow JSON onto the canvas. Fires only when the modal opens, so a trigger that the flag, the two-impression cap, an opt-out, or instance-level MCP access suppresses emits nothing. Each fire consumes one impression.',
|
||||
properties: z.object({
|
||||
surface: nudgeSurface,
|
||||
}),
|
||||
},
|
||||
MCP_NUDGE_CONNECT_CLICKED: {
|
||||
name: 'User clicked connect in MCP nudge',
|
||||
description:
|
||||
'The user clicked "Connect n8n" in the MCP nudge. The app navigates to the MCP settings page and abandons the export or import that opened the nudge. Kept separate from the skip and dismiss events so the connect-to-export click ratio stays computable.',
|
||||
properties: z.object({
|
||||
surface: nudgeSurface,
|
||||
}),
|
||||
},
|
||||
MCP_NUDGE_SKIPPED: {
|
||||
name: 'User skipped MCP nudge',
|
||||
description:
|
||||
'The user clicked "Skip" in the MCP nudge. The export or import that opened the nudge completes unchanged. Closing with × or Escape is "User dismissed MCP nudge" instead.',
|
||||
properties: z.object({
|
||||
surface: nudgeSurface,
|
||||
}),
|
||||
},
|
||||
MCP_NUDGE_DISMISSED: {
|
||||
name: 'User dismissed MCP nudge',
|
||||
description:
|
||||
'The user closed the MCP nudge with × or Escape without choosing an action. The export or import that opened the nudge completes unchanged.',
|
||||
properties: z.object({
|
||||
surface: nudgeSurface,
|
||||
}),
|
||||
},
|
||||
MCP_NUDGE_OPTED_OUT: {
|
||||
name: 'User opted out of MCP nudge',
|
||||
description:
|
||||
'The user checked "Don\'t show this again" in the MCP nudge. Fires when the checkbox is checked, independent of which button later closes the modal. The nudge never shows again for this user.',
|
||||
properties: z.object({
|
||||
surface: nudgeSurface,
|
||||
}),
|
||||
},
|
||||
USER_GAVE_MCP_ACCESS_TO_WORKFLOW: {
|
||||
name: 'User gave MCP access to workflow',
|
||||
description: 'A workflow was exposed over MCP.',
|
||||
|
|
|
|||
|
|
@ -3568,6 +3568,13 @@
|
|||
"experiments.exposeAllWorkflowsToMcp.modal.success.title": "Workflows exposed to MCP",
|
||||
"experiments.exposeAllWorkflowsToMcp.modal.success.message": "{count} workflow is now available to connected clients | {count} workflows are now available to connected clients",
|
||||
"experiments.exposeAllWorkflowsToMcp.modal.error.title": "Error exposing workflows to MCP",
|
||||
"experiments.mcpJsonNudge.modal.export.title": "Exporting this for an AI tool?",
|
||||
"experiments.mcpJsonNudge.modal.import.title": "Importing this from an AI tool?",
|
||||
"experiments.mcpJsonNudge.modal.copy.title": "Copying this for an AI tool?",
|
||||
"experiments.mcpJsonNudge.modal.paste.title": "Pasting this from an AI tool?",
|
||||
"experiments.mcpJsonNudge.modal.body": "Add n8n's MCP connector so that Claude, ChatGPT, or any AI tool can build, edit, and debug this workflow directly",
|
||||
"experiments.mcpJsonNudge.modal.connect": "Connect n8n",
|
||||
"experiments.mcpJsonNudge.modal.skip": "Skip",
|
||||
"experiments.trialIntroModal.eyebrow": "Welcome to n8n",
|
||||
"experiments.trialIntroModal.title": "Your {highlight} has started",
|
||||
"experiments.trialIntroModal.titleHighlight": "free trial",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { useFavoritesStore } from '@/app/stores/favorites.store';
|
|||
import { ResourceType } from '@/features/collaboration/projects/projects.utils';
|
||||
import { useMoveResourceToProjectToast } from '@/features/collaboration/projects/composables/useMoveResourceToProjectToast';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useMcpJsonNudgeTrigger } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeTrigger';
|
||||
|
||||
const props = defineProps<{
|
||||
workflowPermissions: PermissionsRecord['workflow'];
|
||||
|
|
@ -73,6 +74,7 @@ const { showMoveToProjectToast } = useMoveResourceToProjectToast();
|
|||
const workflowTelemetry = useTelemetry();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const mcpJsonNudgeTrigger = useMcpJsonNudgeTrigger();
|
||||
|
||||
const onExecutionsTab = computed(() => {
|
||||
return [
|
||||
|
|
@ -119,7 +121,11 @@ function handleFileImport() {
|
|||
inputRef.value = '';
|
||||
}
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowData', { data: workflowData });
|
||||
// Gate here, at the emitter: NodeView's 'importWorkflowData' handler is shared with
|
||||
// the AI builder's version restore, which must not nudge.
|
||||
void mcpJsonNudgeTrigger.gate('import_file', () =>
|
||||
nodeViewEventBus.emit('importWorkflowData', { data: workflowData }),
|
||||
);
|
||||
};
|
||||
reader.readAsText(inputRef.files[0]);
|
||||
}
|
||||
|
|
@ -386,8 +392,11 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
|
|||
let name = props.name || 'unsaved_workflow';
|
||||
name = sanitizeFilename(name);
|
||||
|
||||
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
|
||||
saveAs(blob, name + '.json');
|
||||
// Inside the gate: an export abandoned via "Connect n8n" must not count as exported.
|
||||
await mcpJsonNudgeTrigger.gate('export', () => {
|
||||
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
|
||||
saveAs(blob, name + '.json');
|
||||
});
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,19 @@ import {
|
|||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { MCP_JSON_NUDGE_MODAL_KEY } from '@/experiments/mcpJsonNudge/constants';
|
||||
import { nodeViewEventBus } from '@/app/event-bus';
|
||||
import { telemetry } from '@/app/plugins/telemetry';
|
||||
|
||||
const mockMcpNudgeCanShow = vi.hoisted(() => vi.fn(() => true));
|
||||
const saveAsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeEligibility', () => ({
|
||||
useMcpJsonNudgeEligibility: () => ({
|
||||
canShow: mockMcpNudgeCanShow,
|
||||
recordImpression: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// No workflow route meta on purpose: the menu renders both on workflow-layout
|
||||
// routes and in host-embedded editors without a workflow route (e.g. the AI
|
||||
|
|
@ -91,6 +104,11 @@ vi.mock('@/app/composables/useWorkflowSaving', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('file-saver', () => ({
|
||||
default: saveAsMock,
|
||||
saveAs: saveAsMock,
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
|
|
@ -195,6 +213,7 @@ describe('WorkflowDetails', () => {
|
|||
|
||||
mockSaveCurrentWorkflow.mockClear();
|
||||
mockSaveCurrentWorkflow.mockResolvedValue(true);
|
||||
mockMcpNudgeCanShow.mockReturnValue(true);
|
||||
workflowsListStore.workflowsById = {
|
||||
'1': workflow,
|
||||
'123': workflow,
|
||||
|
|
@ -250,6 +269,112 @@ describe('WorkflowDetails', () => {
|
|||
});
|
||||
|
||||
describe('Workflow menu', () => {
|
||||
it('shows the MCP JSON nudge and holds the export and its telemetry until the user continues', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
const trackSpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...defaultProps,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
await userEvent.click(getByTestId('workflow-menu-item-download'));
|
||||
|
||||
expect(openModalSpy).toHaveBeenCalledWith({
|
||||
name: MCP_JSON_NUDGE_MODAL_KEY,
|
||||
data: { surface: 'export', onContinue: expect.any(Function) },
|
||||
});
|
||||
expect(saveAsMock).not.toHaveBeenCalled();
|
||||
// Connect abandons the export, so it must not be reported as exported yet.
|
||||
expect(trackSpy).not.toHaveBeenCalledWith('User exported workflow', expect.anything());
|
||||
|
||||
const { onContinue } = openModalSpy.mock.calls[0][0].data as { onContinue: () => void };
|
||||
onContinue();
|
||||
|
||||
expect(saveAsMock).toHaveBeenCalledTimes(1);
|
||||
expect(trackSpy).toHaveBeenCalledWith('User exported workflow', { workflow_id: '1' });
|
||||
});
|
||||
|
||||
it('exports immediately when the MCP JSON nudge is not eligible', async () => {
|
||||
mockMcpNudgeCanShow.mockReturnValue(false);
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
const trackSpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...defaultProps,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
await userEvent.click(getByTestId('workflow-menu-item-download'));
|
||||
|
||||
expect(saveAsMock).toHaveBeenCalledTimes(1);
|
||||
expect(trackSpy).toHaveBeenCalledWith('User exported workflow', { workflow_id: '1' });
|
||||
expect(openModalSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: MCP_JSON_NUDGE_MODAL_KEY }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the MCP JSON nudge and holds the file import until the user continues', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
const emitSpy = vi.spyOn(nodeViewEventBus, 'emit');
|
||||
const workflowData = { nodes: [], connections: {} };
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...defaultProps,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.upload(
|
||||
getByTestId('workflow-import-input'),
|
||||
new File([JSON.stringify(workflowData)], 'workflow.json', { type: 'application/json' }),
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(openModalSpy).toHaveBeenCalledWith({
|
||||
name: MCP_JSON_NUDGE_MODAL_KEY,
|
||||
data: { surface: 'import_file', onContinue: expect.any(Function) },
|
||||
}),
|
||||
);
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('importWorkflowData', expect.anything());
|
||||
|
||||
const { onContinue } = openModalSpy.mock.calls.at(-1)?.[0].data as {
|
||||
onContinue: () => void;
|
||||
};
|
||||
onContinue();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('importWorkflowData', { data: workflowData });
|
||||
});
|
||||
|
||||
it('imports the file immediately when the MCP JSON nudge is not eligible', async () => {
|
||||
mockMcpNudgeCanShow.mockReturnValue(false);
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
const emitSpy = vi.spyOn(nodeViewEventBus, 'emit');
|
||||
const workflowData = { nodes: [], connections: {} };
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...defaultProps,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.upload(
|
||||
getByTestId('workflow-import-input'),
|
||||
new File([JSON.stringify(workflowData)], 'workflow.json', { type: 'application/json' }),
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('importWorkflowData', { data: workflowData }),
|
||||
);
|
||||
expect(openModalSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: MCP_JSON_NUDGE_MODAL_KEY }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not have workflow duplicate and import when branch is read-only', async () => {
|
||||
sourceControlStore.preferences.branchReadOnly = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ export const OPEN_WORKFLOW_IN_ASSISTANT_EXPERIMENT = createExperiment(
|
|||
'108_open_workflow_in_assistant',
|
||||
);
|
||||
|
||||
export const MCP_JSON_NUDGE_EXPERIMENT = createExperiment('113_mcp_nudge_modal_on_export_import');
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
|
|
@ -181,4 +183,5 @@ export const EXPERIMENTS_TO_TRACK = [
|
|||
INLINE_AGENTS_EXPERIMENT.name,
|
||||
INSTANCE_AI_FREE_NUDGE_EXPERIMENT.name,
|
||||
OPEN_WORKFLOW_IN_ASSISTANT_EXPERIMENT.name,
|
||||
MCP_JSON_NUDGE_EXPERIMENT.name,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { EVALUATION_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import {
|
||||
createWorkflowDocumentId,
|
||||
|
|
@ -16,6 +17,15 @@ import NodeView from './NodeView.vue';
|
|||
import { VIEWS } from '../constants';
|
||||
import { WorkflowIdKey, WorkflowDocumentStoreKey } from '../constants/injectionKeys';
|
||||
import { computed, defineComponent, shallowRef } from 'vue';
|
||||
import { nodeViewEventBus } from '@/app/event-bus';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import type { Project } from '@/features/collaboration/projects/projects.types';
|
||||
|
||||
const mockMcpJsonNudgeGate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeTrigger', () => ({
|
||||
useMcpJsonNudgeTrigger: () => ({ gate: mockMcpJsonNudgeGate }),
|
||||
}));
|
||||
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
|
|
@ -53,8 +63,11 @@ describe('NodeView', () => {
|
|||
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
|
||||
let ensureNodesAreVisible: ReturnType<typeof vi.fn>;
|
||||
let workflowExecutionState: ReturnType<typeof useWorkflowExecutionStateStore>;
|
||||
// Node ids the WorkflowCanvas stub emits with `copy:nodes` when its copy button is clicked.
|
||||
let copyNodeIds: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
copyNodeIds = [];
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('localStorage', {
|
||||
|
|
@ -63,6 +76,7 @@ describe('NodeView', () => {
|
|||
routeMock.name = undefined;
|
||||
routeMock.params = {};
|
||||
routeMock.query = {};
|
||||
routeMock.meta = {};
|
||||
ensureNodesAreVisible = vi.fn();
|
||||
workflowsStore = useWorkflowsStore();
|
||||
workflowsStore.setWorkflowId('w0');
|
||||
|
|
@ -82,11 +96,18 @@ describe('NodeView', () => {
|
|||
[WorkflowDocumentStoreKey as symbol]: shallowRef(workflowDocStore),
|
||||
},
|
||||
stubs: {
|
||||
// The node creator is an async component that pulls in a large subtree. No
|
||||
// test here needs it, and on a writable canvas the import can still be in
|
||||
// flight when the environment tears down, which fails the whole run.
|
||||
LazyNodeCreation: true,
|
||||
WorkflowCanvas: defineComponent({
|
||||
emits: ['copy:nodes'],
|
||||
setup(_, { expose }) {
|
||||
expose({ ensureNodesAreVisible });
|
||||
return { copyNodeIds };
|
||||
},
|
||||
template: '<div><slot /></div>',
|
||||
template:
|
||||
'<div><button data-test-id="canvas-stub-copy" @click="$emit(\'copy:nodes\', copyNodeIds)" /><slot /></div>',
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
|
@ -246,4 +267,197 @@ describe('NodeView', () => {
|
|||
expect(await findByTestId('execute-workflow-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import / Export', () => {
|
||||
const imported = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'Imported' });
|
||||
|
||||
const workflowData = { nodes: [imported], connections: {} };
|
||||
let deferred: (() => void | Promise<void>) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
useNodeTypesStore().setNodeTypes([
|
||||
mockNodeTypeDescription({ name: MANUAL_TRIGGER_NODE_TYPE, group: ['trigger'] }),
|
||||
]);
|
||||
// The nudge is "shown": the gate captures the action instead of running it.
|
||||
deferred = undefined;
|
||||
mockMcpJsonNudgeGate.mockReset().mockImplementation(async (_surface, action) => {
|
||||
deferred = action;
|
||||
});
|
||||
// fetchWorkflowDataFromUrl needs a project and the URL fetch stubbed.
|
||||
useProjectsStore().personalProject = { id: 'personal', name: 'Personal' } as Project;
|
||||
vi.spyOn(workflowsStore, 'getWorkflowFromUrl').mockResolvedValue(workflowData as never);
|
||||
});
|
||||
|
||||
// The AI builder's version restore emits the same event, so the nudge must not sit
|
||||
// here. The file-import gate lives at the emitter in ActionsDropdownMenu instead.
|
||||
it('lands imported nodes without gating the importWorkflowData event behind the MCP nudge', async () => {
|
||||
renderNodeView();
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowData', { data: workflowData });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Imported']),
|
||||
);
|
||||
expect(mockMcpJsonNudgeGate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds a URL import behind the import_url nudge and lands the nodes when it continues', async () => {
|
||||
renderNodeView();
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowUrl', { url: 'https://example.com/workflow.json' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('import_url', expect.any(Function)),
|
||||
);
|
||||
expect(workflowDocumentStore.allNodes).toHaveLength(0);
|
||||
|
||||
await deferred?.();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Imported']),
|
||||
);
|
||||
});
|
||||
|
||||
// The modal lives at the app root and outlives this view. If the user navigates away
|
||||
// while it is open, continuing must not import into whatever workflow is now shown.
|
||||
it('drops a deferred URL import once NodeView has unmounted', async () => {
|
||||
const { unmount } = renderNodeView();
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowUrl', { url: 'https://example.com/workflow.json' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('import_url', expect.any(Function)),
|
||||
);
|
||||
unmount();
|
||||
|
||||
await deferred?.();
|
||||
|
||||
expect(workflowDocumentStore.allNodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy / Paste', () => {
|
||||
const existing = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'Existing' });
|
||||
const pasted = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'Pasted' });
|
||||
const pastedJson = JSON.stringify({ nodes: [pasted], connections: {} });
|
||||
|
||||
let deferred: (() => void | Promise<void>) | undefined;
|
||||
// jsdom has no clipboard API. With `navigator.clipboard` present but no write
|
||||
// permission, vueuse falls back to a textarea + execCommand('copy'), which we capture.
|
||||
let copiedText: string[] = [];
|
||||
|
||||
function pasteText(text: string) {
|
||||
const event = new Event('paste');
|
||||
Object.assign(event, { clipboardData: { getData: () => text } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useNodeTypesStore().setNodeTypes([
|
||||
mockNodeTypeDescription({ name: MANUAL_TRIGGER_NODE_TYPE, group: ['trigger'] }),
|
||||
]);
|
||||
// Paste only runs on the workflow tab of a writable canvas.
|
||||
routeMock.meta = { nodeView: true };
|
||||
useWorkflowsListStore().addWorkflow(
|
||||
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
|
||||
);
|
||||
workflowDocumentStore.setNodes([existing]);
|
||||
copyNodeIds = [existing.id];
|
||||
|
||||
deferred = undefined;
|
||||
mockMcpJsonNudgeGate.mockReset().mockImplementation(async (_surface, action) => {
|
||||
deferred = action;
|
||||
});
|
||||
|
||||
copiedText = [];
|
||||
Object.defineProperty(window.navigator, 'clipboard', { value: {}, configurable: true });
|
||||
document.execCommand = vi.fn(() => {
|
||||
const textarea = document.body.lastElementChild;
|
||||
if (textarea instanceof HTMLTextAreaElement) copiedText.push(textarea.value);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window.navigator, 'clipboard');
|
||||
});
|
||||
|
||||
it('holds a copy behind the copy nudge and writes to the clipboard when it continues', async () => {
|
||||
const { findByTestId } = renderNodeView();
|
||||
|
||||
await userEvent.click(await findByTestId('canvas-stub-copy'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('copy', expect.any(Function)),
|
||||
);
|
||||
expect(copiedText).toEqual([]);
|
||||
|
||||
await deferred?.();
|
||||
|
||||
expect(copiedText).toHaveLength(1);
|
||||
expect(copiedText[0]).toContain('"name": "Existing"');
|
||||
});
|
||||
|
||||
it('drops a deferred copy once NodeView has unmounted', async () => {
|
||||
const { findByTestId, unmount } = renderNodeView();
|
||||
|
||||
await userEvent.click(await findByTestId('canvas-stub-copy'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('copy', expect.any(Function)),
|
||||
);
|
||||
unmount();
|
||||
|
||||
await deferred?.();
|
||||
|
||||
expect(copiedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('holds a paste behind the paste nudge and lands the nodes when it continues', async () => {
|
||||
renderNodeView();
|
||||
|
||||
pasteText(pastedJson);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('paste', expect.any(Function)),
|
||||
);
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Existing']);
|
||||
|
||||
await deferred?.();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual([
|
||||
'Existing',
|
||||
'Pasted',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not open the paste nudge for clipboard text that is not workflow JSON', async () => {
|
||||
renderNodeView();
|
||||
|
||||
pasteText('just some text');
|
||||
|
||||
// Give the async paste handler a tick to settle before asserting nothing happened.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockMcpJsonNudgeGate).not.toHaveBeenCalled();
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Existing']);
|
||||
});
|
||||
|
||||
it('drops a deferred paste once NodeView has unmounted', async () => {
|
||||
const { unmount } = renderNodeView();
|
||||
|
||||
pasteText(pastedJson);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockMcpJsonNudgeGate).toHaveBeenCalledWith('paste', expect.any(Function)),
|
||||
);
|
||||
unmount();
|
||||
|
||||
await deferred?.();
|
||||
|
||||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Existing']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/
|
|||
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
|
||||
import { usePostMessageControls } from '@/app/composables/usePostMessageHandler';
|
||||
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
|
||||
import { useMcpJsonNudgeTrigger } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeTrigger';
|
||||
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
|
||||
import { useWorkflowExtraction } from '@/app/composables/useWorkflowExtraction';
|
||||
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
||||
|
|
@ -209,6 +210,7 @@ const evaluationsWizardSidepanelStore = useEvaluationsWizardSidepanelStore();
|
|||
const { isFeatureEnabled: isEvaluationsWizardSidepanelEnabled } =
|
||||
useEvaluationsWizardSidepanelExperiment();
|
||||
const builderStore = useBuilderStore();
|
||||
const mcpJsonNudgeTrigger = useMcpJsonNudgeTrigger();
|
||||
const agentRequestStore = useAgentRequestStore();
|
||||
const logsStore = useLogsStore();
|
||||
const experimentalNdvStore = useExperimentalNdvStore();
|
||||
|
|
@ -593,10 +595,27 @@ function onSetNodeSelected(id?: string) {
|
|||
setNodeSelected(id);
|
||||
}
|
||||
|
||||
async function onCopyNodes(ids: string[]) {
|
||||
await copyNodes(ids);
|
||||
// The MCP nudge modal lives at the app root and outlives this view. If the user navigates
|
||||
// away while it is open, a deferred copy, paste, or import must not run against whatever
|
||||
// workflow is shown by then.
|
||||
let isUnmounted = false;
|
||||
function isStillOnWorkflow(originWorkflowId: string) {
|
||||
return !isUnmounted && workflowId.value === originWorkflowId;
|
||||
}
|
||||
|
||||
toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' });
|
||||
async function onCopyNodes(ids: string[]) {
|
||||
const originWorkflowId = workflowId.value;
|
||||
const copy = async () => {
|
||||
if (!isStillOnWorkflow(originWorkflowId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await copyNodes(ids);
|
||||
|
||||
toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' });
|
||||
};
|
||||
|
||||
await mcpJsonNudgeTrigger.gate('copy', copy);
|
||||
}
|
||||
|
||||
async function onClipboardPaste(plainTextData: string): Promise<void> {
|
||||
|
|
@ -643,13 +662,22 @@ async function onClipboardPaste(plainTextData: string): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await importWorkflowData(workflowData, 'paste', {
|
||||
importTags: false,
|
||||
viewport: viewportBoundaries.value,
|
||||
});
|
||||
const ids = result.nodes?.map((node) => node.id) ?? [];
|
||||
const originWorkflowId = workflowId.value;
|
||||
const paste = async () => {
|
||||
if (!isStillOnWorkflow(originWorkflowId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvasRef.value?.ensureNodesAreVisible(ids);
|
||||
const result = await importWorkflowData(workflowData, 'paste', {
|
||||
importTags: false,
|
||||
viewport: viewportBoundaries.value,
|
||||
});
|
||||
const ids = result.nodes?.map((node) => node.id) ?? [];
|
||||
|
||||
canvasRef.value?.ensureNodesAreVisible(ids);
|
||||
};
|
||||
|
||||
await mcpJsonNudgeTrigger.gate('paste', paste);
|
||||
}
|
||||
|
||||
async function onCutNodes(ids: string[]) {
|
||||
|
|
@ -924,11 +952,20 @@ async function onImportWorkflowUrlEvent(data: IDataObject) {
|
|||
return;
|
||||
}
|
||||
|
||||
await importWorkflowData(workflowData, 'url', {
|
||||
viewport: viewportBoundaries.value,
|
||||
});
|
||||
const originWorkflowId = workflowId.value;
|
||||
const importUrl = async () => {
|
||||
if (!isStillOnWorkflow(originWorkflowId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvasRef.value?.ensureNodesAreVisible(workflowData.nodes?.map((node) => node.id) ?? []);
|
||||
await importWorkflowData(workflowData, 'url', {
|
||||
viewport: viewportBoundaries.value,
|
||||
});
|
||||
|
||||
canvasRef.value?.ensureNodesAreVisible(workflowData.nodes?.map((node) => node.id) ?? []);
|
||||
};
|
||||
|
||||
await mcpJsonNudgeTrigger.gate('import_url', importUrl);
|
||||
}
|
||||
|
||||
function addImportEventBindings() {
|
||||
|
|
@ -1972,6 +2009,7 @@ onMounted(async () => {
|
|||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
uiStore.closeModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
toast.clearAllStickyNotifications();
|
||||
workflowDocumentStore?.value?.setViewport(viewportTransform.value);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { McpJsonNudgeAction } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeTrigger';
|
||||
import type { McpJsonNudgeSurface } from '@/experiments/mcpJsonNudge/constants';
|
||||
import { MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Mock } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import McpJsonNudgeModal from './McpJsonNudgeModal.vue';
|
||||
|
||||
const routerPushMock = vi.hoisted(() => vi.fn());
|
||||
const closeMock = vi.hoisted(() => vi.fn());
|
||||
const dismissForeverMock = vi.hoisted(() => vi.fn());
|
||||
const trackMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useRouter: () => ({ push: routerPushMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeEligibility', () => ({
|
||||
useMcpJsonNudgeEligibility: () => ({ dismissForever: dismissForeverMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: trackMock }),
|
||||
}));
|
||||
|
||||
// Mirrors the real Modal: the footer `close` fn closes the modal, and every
|
||||
// close (button or × / esc) emits 'closed' on the modal's event bus.
|
||||
const ModalStub = defineComponent({
|
||||
props: ['name', 'title', 'eventBus'],
|
||||
methods: {
|
||||
close() {
|
||||
closeMock();
|
||||
this.eventBus?.emit('closed');
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div :data-test-id="name">
|
||||
<h1>{{ title }}</h1>
|
||||
<slot name="content" />
|
||||
<slot name="footer" :close="close" />
|
||||
<button data-test-id="mcp-json-nudge-generic-close" @click="eventBus.emit('closed')" />
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
const McpClientLogoCardsStub = defineComponent({
|
||||
template: '<div data-test-id="mcp-json-nudge-logo-cards" />',
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(McpJsonNudgeModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: ModalStub,
|
||||
McpClientLogoCards: McpClientLogoCardsStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('McpJsonNudgeModal', () => {
|
||||
let onContinue: Mock<McpJsonNudgeAction>;
|
||||
|
||||
beforeEach(() => {
|
||||
routerPushMock.mockClear();
|
||||
closeMock.mockClear();
|
||||
dismissForeverMock.mockClear();
|
||||
trackMock.mockClear();
|
||||
onContinue = vi.fn<McpJsonNudgeAction>();
|
||||
});
|
||||
|
||||
const renderWith = (surface: McpJsonNudgeSurface = 'export') =>
|
||||
renderComponent({ props: { data: { surface, onContinue } } });
|
||||
|
||||
it.each([
|
||||
['export', 'Exporting this for an AI tool?'],
|
||||
['import_file', 'Importing this from an AI tool?'],
|
||||
['import_url', 'Importing this from an AI tool?'],
|
||||
['copy', 'Copying this for an AI tool?'],
|
||||
['paste', 'Pasting this from an AI tool?'],
|
||||
] as const)('shows the right header for the %s surface', (surface, expectedTitle) => {
|
||||
const { getByText } = renderWith(surface);
|
||||
|
||||
expect(getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the logo cards, body copy, and both actions', () => {
|
||||
const { getByTestId, getByText } = renderWith();
|
||||
|
||||
expect(getByTestId('mcp-json-nudge-logo-cards')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText(
|
||||
"Add n8n's MCP connector so that Claude, ChatGPT, or any AI tool can build, edit, and debug this workflow directly",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(getByTestId('mcp-json-nudge-skip-button')).toBeInTheDocument();
|
||||
expect(getByTestId('mcp-json-nudge-connect-button')).toBeInTheDocument();
|
||||
expect(getByTestId('mcp-json-nudge-dont-show-again')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('continues the original action once, closes, and tracks the skip when Skip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = renderWith('import_url');
|
||||
|
||||
await user.click(getByTestId('mcp-json-nudge-skip-button'));
|
||||
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
expect(closeMock).toHaveBeenCalled();
|
||||
expect(routerPushMock).not.toHaveBeenCalled();
|
||||
expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.MCP_NUDGE_SKIPPED, {
|
||||
surface: 'import_url',
|
||||
});
|
||||
expect(trackMock).not.toHaveBeenCalledWith(
|
||||
TELEMETRY_EVENT.MCP.MCP_NUDGE_DISMISSED,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons the original action, navigates to MCP settings, closes, and tracks the connect click when Connect n8n is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = renderWith();
|
||||
|
||||
await user.click(getByTestId('mcp-json-nudge-connect-button'));
|
||||
|
||||
expect(routerPushMock).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW });
|
||||
expect(closeMock).toHaveBeenCalled();
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.MCP_NUDGE_CONNECT_CLICKED, {
|
||||
surface: 'export',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues the original action once and tracks the dismissal when closed without an action (× / esc)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = renderWith();
|
||||
|
||||
await user.click(getByTestId('mcp-json-nudge-generic-close'));
|
||||
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
expect(routerPushMock).not.toHaveBeenCalled();
|
||||
expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.MCP_NUDGE_DISMISSED, {
|
||||
surface: 'export',
|
||||
});
|
||||
expect(trackMock).not.toHaveBeenCalledWith(
|
||||
TELEMETRY_EVENT.MCP.MCP_NUDGE_SKIPPED,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('dismisses the nudge forever and tracks the opt-out when "Don\'t show this again" is checked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = renderWith();
|
||||
|
||||
await user.click(getByTestId('mcp-json-nudge-dont-show-again'));
|
||||
|
||||
expect(dismissForeverMock).toHaveBeenCalled();
|
||||
expect(trackMock).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.MCP_NUDGE_OPTED_OUT, {
|
||||
surface: 'export',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
<script setup lang="ts">
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { useMcpJsonNudgeEligibility } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeEligibility';
|
||||
import type { McpJsonNudgeAction } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeTrigger';
|
||||
import McpClientLogoCards from '@/features/ai/mcpAccess/components/McpClientLogoCards.vue';
|
||||
import { MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { N8nButton, N8nCheckbox, N8nText } from '@n8n/design-system';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
MCP_JSON_NUDGE_MODAL_KEY,
|
||||
type McpJsonNudgeSurface,
|
||||
} from '@/experiments/mcpJsonNudge/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
surface: McpJsonNudgeSurface;
|
||||
/** The gated export/import. Runs on Skip or dismiss; Connect abandons it. */
|
||||
onContinue?: McpJsonNudgeAction;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const eligibility = useMcpJsonNudgeEligibility();
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const closedByAction = ref(false);
|
||||
const dontShowAgain = ref(false);
|
||||
|
||||
const TITLE_KEY_BY_SURFACE = {
|
||||
export: 'experiments.mcpJsonNudge.modal.export.title',
|
||||
import_file: 'experiments.mcpJsonNudge.modal.import.title',
|
||||
import_url: 'experiments.mcpJsonNudge.modal.import.title',
|
||||
copy: 'experiments.mcpJsonNudge.modal.copy.title',
|
||||
paste: 'experiments.mcpJsonNudge.modal.paste.title',
|
||||
} as const satisfies Record<McpJsonNudgeSurface, BaseTextKey>;
|
||||
|
||||
const title = computed(() => i18n.baseText(TITLE_KEY_BY_SURFACE[props.data.surface]));
|
||||
|
||||
function onDontShowAgainChange(value: boolean) {
|
||||
dontShowAgain.value = value;
|
||||
if (value) {
|
||||
telemetry.track(TELEMETRY_EVENT.MCP.MCP_NUDGE_OPTED_OUT, { surface: props.data.surface });
|
||||
void eligibility.dismissForever();
|
||||
}
|
||||
}
|
||||
|
||||
function onConnect(close: () => void) {
|
||||
closedByAction.value = true;
|
||||
telemetry.track(TELEMETRY_EVENT.MCP.MCP_NUDGE_CONNECT_CLICKED, { surface: props.data.surface });
|
||||
void router.push({ name: MCP_SETTINGS_VIEW });
|
||||
close();
|
||||
}
|
||||
|
||||
function onSkip(close: () => void) {
|
||||
closedByAction.value = true;
|
||||
telemetry.track(TELEMETRY_EVENT.MCP.MCP_NUDGE_SKIPPED, { surface: props.data.surface });
|
||||
void props.data.onContinue?.();
|
||||
close();
|
||||
}
|
||||
|
||||
// × / esc / click-outside: the user did not pick an action, so the original
|
||||
// export/import still completes.
|
||||
function onModalClosed() {
|
||||
if (!closedByAction.value) {
|
||||
telemetry.track(TELEMETRY_EVENT.MCP.MCP_NUDGE_DISMISSED, { surface: props.data.surface });
|
||||
void props.data.onContinue?.();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
modalBus.on('closed', onModalClosed);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
modalBus.off('closed', onModalClosed);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :name="MCP_JSON_NUDGE_MODAL_KEY" :title="title" width="480px" :event-bus="modalBus">
|
||||
<template #content>
|
||||
<McpClientLogoCards :class="$style.logoCards" />
|
||||
<N8nText color="text-base">
|
||||
{{ i18n.baseText('experiments.mcpJsonNudge.modal.body') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div :class="$style.footer">
|
||||
<N8nCheckbox
|
||||
:model-value="dontShowAgain"
|
||||
data-test-id="mcp-json-nudge-dont-show-again"
|
||||
@update:model-value="onDontShowAgainChange"
|
||||
>
|
||||
<template #label>{{ i18n.baseText('generic.dontShowAgain') }}</template>
|
||||
</N8nCheckbox>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('experiments.mcpJsonNudge.modal.skip')"
|
||||
data-test-id="mcp-json-nudge-skip-button"
|
||||
@click="onSkip(close)"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="small"
|
||||
:label="i18n.baseText('experiments.mcpJsonNudge.modal.connect')"
|
||||
data-test-id="mcp-json-nudge-connect-button"
|
||||
@click="onConnect(close)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.logoCards {
|
||||
/* The tiles rotate ±8deg; Modal's content area clips (overflow: hidden,
|
||||
near-zero inline padding), so the rotated corners need breathing room. */
|
||||
padding: var(--spacing--xs) var(--spacing--md);
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { EXPERIMENTS_TO_TRACK, MCP_JSON_NUDGE_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import { MCP_JSON_NUDGE_CALLOUT } from '@/experiments/mcpJsonNudge/constants';
|
||||
|
||||
const mockMcpStore = vi.hoisted(() => ({ mcpAccessEnabled: false }));
|
||||
const mockIsFeatureEnabled = vi.hoisted(() => vi.fn());
|
||||
const mockIsCalloutDismissed = vi.hoisted(() => vi.fn());
|
||||
const mockSetCalloutDismissed = vi.hoisted(() => vi.fn());
|
||||
const mockCurrentUser = vi.hoisted<{ settings: Record<string, unknown> }>(() => ({ settings: {} }));
|
||||
const mockUpdateUserSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
|
||||
useMCPStore: () => mockMcpStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/posthog.store', () => ({
|
||||
usePostHog: () => ({ isFeatureEnabled: mockIsFeatureEnabled }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/users.store', () => ({
|
||||
useUsersStore: () => ({
|
||||
currentUser: mockCurrentUser,
|
||||
isCalloutDismissed: mockIsCalloutDismissed,
|
||||
setCalloutDismissed: mockSetCalloutDismissed,
|
||||
updateUserSettings: mockUpdateUserSettings,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useMcpJsonNudgeEligibility } from './useMcpJsonNudgeEligibility';
|
||||
|
||||
describe('useMcpJsonNudgeEligibility', () => {
|
||||
beforeEach(() => {
|
||||
mockMcpStore.mcpAccessEnabled = false;
|
||||
mockIsFeatureEnabled.mockReset().mockReturnValue(true);
|
||||
mockIsCalloutDismissed.mockReset().mockReturnValue(false);
|
||||
mockSetCalloutDismissed.mockClear();
|
||||
mockCurrentUser.settings = {};
|
||||
mockUpdateUserSettings.mockClear();
|
||||
});
|
||||
|
||||
it('is registered in EXPERIMENTS_TO_TRACK', () => {
|
||||
expect(EXPERIMENTS_TO_TRACK).toContain(MCP_JSON_NUDGE_EXPERIMENT.name);
|
||||
});
|
||||
|
||||
describe('canShow', () => {
|
||||
it('returns true when every condition is favorable (baseline)', () => {
|
||||
const { canShow } = useMcpJsonNudgeEligibility();
|
||||
|
||||
expect(canShow()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when MCP is already enabled for the instance', () => {
|
||||
mockMcpStore.mcpAccessEnabled = true;
|
||||
const { canShow } = useMcpJsonNudgeEligibility();
|
||||
|
||||
expect(canShow()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the feature flag is off', () => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
const { canShow } = useMcpJsonNudgeEligibility();
|
||||
|
||||
expect(canShow()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the user dismissed it forever', () => {
|
||||
mockIsCalloutDismissed.mockReturnValue(true);
|
||||
const { canShow } = useMcpJsonNudgeEligibility();
|
||||
|
||||
expect(canShow()).toBe(false);
|
||||
});
|
||||
|
||||
// Boundary value analysis on the impression cap (< 2): 0 and 1 are
|
||||
// inside the accepted range, 2 is the boundary itself (first
|
||||
// rejected value), 3 is outside range, and a missing count is a
|
||||
// distinct equivalence class (no prior impressions recorded) that
|
||||
// must be treated the same as 0, not as invalid/false.
|
||||
it.each([
|
||||
[undefined, true, 'missing impressions count (treated as zero)'],
|
||||
[0, true, 'zero impressions (lower bound of the valid range)'],
|
||||
[1, true, 'one impression (just below the cap)'],
|
||||
[2, false, 'two impressions (the cap itself, first rejected value)'],
|
||||
[3, false, 'three impressions (above the cap)'],
|
||||
] as const)('impressions=%s -> %s (%s)', (impressions, expected, _label) => {
|
||||
mockCurrentUser.settings = impressions === undefined ? {} : { mcpJsonNudge: { impressions } };
|
||||
const { canShow } = useMcpJsonNudgeEligibility();
|
||||
|
||||
expect(canShow()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordImpression', () => {
|
||||
it('persists 1 when there was no prior impressions count', async () => {
|
||||
mockCurrentUser.settings = {};
|
||||
const { recordImpression } = useMcpJsonNudgeEligibility();
|
||||
|
||||
await recordImpression();
|
||||
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledWith({ mcpJsonNudge: { impressions: 1 } });
|
||||
});
|
||||
|
||||
it('increments an existing impressions count', async () => {
|
||||
mockCurrentUser.settings = { mcpJsonNudge: { impressions: 1 } };
|
||||
const { recordImpression } = useMcpJsonNudgeEligibility();
|
||||
|
||||
await recordImpression();
|
||||
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledWith({ mcpJsonNudge: { impressions: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissForever', () => {
|
||||
it('dismisses the MCP JSON nudge callout, preserving other dismissed callouts', async () => {
|
||||
mockCurrentUser.settings = { dismissedCallouts: { someOtherCallout: true } };
|
||||
const { dismissForever } = useMcpJsonNudgeEligibility();
|
||||
|
||||
await dismissForever();
|
||||
|
||||
expect(mockSetCalloutDismissed).toHaveBeenCalledWith(MCP_JSON_NUDGE_CALLOUT);
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledWith({
|
||||
dismissedCallouts: { someOtherCallout: true, [MCP_JSON_NUDGE_CALLOUT]: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { MCP_JSON_NUDGE_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { MCP_JSON_NUDGE_CALLOUT } from '@/experiments/mcpJsonNudge/constants';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
|
||||
const IMPRESSION_CAP = 2;
|
||||
|
||||
export function useMcpJsonNudgeEligibility() {
|
||||
const mcpStore = useMCPStore();
|
||||
const posthogStore = usePostHog();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
function canShow(): boolean {
|
||||
const impressions = usersStore.currentUser?.settings?.mcpJsonNudge?.impressions ?? 0;
|
||||
|
||||
return (
|
||||
!mcpStore.mcpAccessEnabled &&
|
||||
posthogStore.isFeatureEnabled(MCP_JSON_NUDGE_EXPERIMENT.name) &&
|
||||
impressions < IMPRESSION_CAP &&
|
||||
!usersStore.isCalloutDismissed(MCP_JSON_NUDGE_CALLOUT)
|
||||
);
|
||||
}
|
||||
|
||||
async function recordImpression(): Promise<void> {
|
||||
const impressions = (usersStore.currentUser?.settings?.mcpJsonNudge?.impressions ?? 0) + 1;
|
||||
|
||||
// Write locally first: the caller does not await this, and canShow() must see
|
||||
// the new count if the user exports again before the request comes back.
|
||||
if (usersStore.currentUser?.settings) {
|
||||
usersStore.currentUser.settings.mcpJsonNudge = { impressions };
|
||||
}
|
||||
|
||||
await usersStore.updateUserSettings({ mcpJsonNudge: { impressions } });
|
||||
}
|
||||
|
||||
async function dismissForever(): Promise<void> {
|
||||
usersStore.setCalloutDismissed(MCP_JSON_NUDGE_CALLOUT);
|
||||
|
||||
// updateUserSettings mirrors the response back into currentUser.settings, which
|
||||
// also covers fresh users whose settings are still null (setCalloutDismissed
|
||||
// above no-ops for them).
|
||||
await usersStore.updateUserSettings({
|
||||
dismissedCallouts: {
|
||||
...usersStore.currentUser?.settings?.dismissedCallouts,
|
||||
[MCP_JSON_NUDGE_CALLOUT]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { canShow, recordImpression, dismissForever };
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
const mockOpenModalWithData = vi.fn();
|
||||
const mockIsModalActiveById = vi.hoisted(() => ({}) as Record<string, boolean>);
|
||||
const mockCanShow = vi.hoisted(() => vi.fn());
|
||||
const mockRecordImpression = vi.hoisted(() => vi.fn());
|
||||
const mockTrack = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({
|
||||
openModalWithData: mockOpenModalWithData,
|
||||
isModalActiveById: mockIsModalActiveById,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeEligibility', () => ({
|
||||
useMcpJsonNudgeEligibility: () => ({
|
||||
canShow: mockCanShow,
|
||||
recordImpression: mockRecordImpression,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: mockTrack }),
|
||||
}));
|
||||
|
||||
import { MCP_JSON_NUDGE_MODAL_KEY } from '@/experiments/mcpJsonNudge/constants';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { useMcpJsonNudgeTrigger } from './useMcpJsonNudgeTrigger';
|
||||
|
||||
describe('useMcpJsonNudgeTrigger', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenModalWithData.mockClear();
|
||||
for (const key of Object.keys(mockIsModalActiveById)) delete mockIsModalActiveById[key];
|
||||
mockCanShow.mockReset().mockReturnValue(true);
|
||||
mockRecordImpression.mockClear();
|
||||
mockTrack.mockClear();
|
||||
});
|
||||
|
||||
// A second export/import can start while the nudge is open (e.g. via the command
|
||||
// palette). It must not replace the pending onContinue and lose the first action.
|
||||
it('runs the action immediately when the nudge modal is already open', async () => {
|
||||
mockIsModalActiveById[MCP_JSON_NUDGE_MODAL_KEY] = true;
|
||||
const action = vi.fn();
|
||||
const { gate } = useMcpJsonNudgeTrigger();
|
||||
|
||||
await gate('import_file', action);
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenModalWithData).not.toHaveBeenCalled();
|
||||
expect(mockRecordImpression).not.toHaveBeenCalled();
|
||||
expect(mockTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when eligible', () => {
|
||||
it.each(['export', 'import_file', 'import_url', 'copy', 'paste'] as const)(
|
||||
'opens the modal for the %s surface, defers the action to onContinue, records an impression, and tracks the view',
|
||||
async (surface) => {
|
||||
const action = vi.fn();
|
||||
const { gate } = useMcpJsonNudgeTrigger();
|
||||
|
||||
await gate(surface, action);
|
||||
|
||||
expect(mockOpenModalWithData).toHaveBeenCalledWith({
|
||||
name: MCP_JSON_NUDGE_MODAL_KEY,
|
||||
data: { surface, onContinue: action },
|
||||
});
|
||||
expect(mockRecordImpression).toHaveBeenCalled();
|
||||
expect(mockTrack).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.MCP_NUDGE_VIEWED, { surface });
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when ineligible', () => {
|
||||
it('runs the action immediately without opening the modal, recording an impression, or tracking', async () => {
|
||||
mockCanShow.mockReturnValue(false);
|
||||
const action = vi.fn();
|
||||
const { gate } = useMcpJsonNudgeTrigger();
|
||||
|
||||
await gate('export', action);
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenModalWithData).not.toHaveBeenCalled();
|
||||
expect(mockRecordImpression).not.toHaveBeenCalled();
|
||||
expect(mockTrack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('awaits an async action', async () => {
|
||||
mockCanShow.mockReturnValue(false);
|
||||
let settled = false;
|
||||
const action = vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
settled = true;
|
||||
});
|
||||
const { gate } = useMcpJsonNudgeTrigger();
|
||||
|
||||
await gate('import_file', action);
|
||||
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useMcpJsonNudgeEligibility } from '@/experiments/mcpJsonNudge/composables/useMcpJsonNudgeEligibility';
|
||||
import {
|
||||
MCP_JSON_NUDGE_MODAL_KEY,
|
||||
type McpJsonNudgeSurface,
|
||||
} from '@/experiments/mcpJsonNudge/constants';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
|
||||
export type McpJsonNudgeAction = () => void | Promise<void>;
|
||||
|
||||
export function useMcpJsonNudgeTrigger() {
|
||||
const uiStore = useUIStore();
|
||||
const eligibility = useMcpJsonNudgeEligibility();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
/**
|
||||
* Runs `action` (the export or import) behind the nudge. When the nudge is
|
||||
* eligible, the modal opens and the action is deferred to its `onContinue`
|
||||
* (Skip / dismiss); Connect abandons it. Otherwise the action runs right away.
|
||||
*/
|
||||
async function gate(surface: McpJsonNudgeSurface, action: McpJsonNudgeAction): Promise<void> {
|
||||
// A second export/import can start while the nudge is open (e.g. from the command
|
||||
// palette). Opening again would replace the pending onContinue and lose the first
|
||||
// action, so let the new one through untouched.
|
||||
const nudgeAlreadyOpen = uiStore.isModalActiveById[MCP_JSON_NUDGE_MODAL_KEY];
|
||||
|
||||
if (nudgeAlreadyOpen || !eligibility.canShow()) {
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: MCP_JSON_NUDGE_MODAL_KEY,
|
||||
data: { surface, onContinue: action },
|
||||
});
|
||||
telemetry.track(TELEMETRY_EVENT.MCP.MCP_NUDGE_VIEWED, { surface });
|
||||
void eligibility.recordImpression();
|
||||
}
|
||||
|
||||
return { gate };
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export const MCP_JSON_NUDGE_MODAL_KEY = 'mcpJsonNudgeModal';
|
||||
|
||||
export const MCP_JSON_NUDGE_CALLOUT = 'MCP_JSON_NUDGE';
|
||||
|
||||
export type McpJsonNudgeSurface = 'export' | 'import_file' | 'import_url' | 'copy' | 'paste';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { ModalDefinition } from '@n8n/frontend-module-sdk';
|
||||
import { MCP_JSON_NUDGE_MODAL_KEY } from './constants';
|
||||
|
||||
export const MCP_JSON_NUDGE_MODALS: ModalDefinition[] = [
|
||||
{
|
||||
key: MCP_JSON_NUDGE_MODAL_KEY,
|
||||
component: async () => await import('./components/McpJsonNudgeModal.vue'),
|
||||
initialState: { open: false },
|
||||
},
|
||||
];
|
||||
|
|
@ -2,6 +2,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { type FrontendModuleDescription } from '@n8n/frontend-module-sdk';
|
||||
import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODALS } from '@/experiments/exposeAllWorkflowsToMcp/modals';
|
||||
import { SURFACE_MCP_TO_NEW_CLOUD_USERS_MODALS } from '@/experiments/surfaceMcpToNewCloudUsers/modals';
|
||||
import { MCP_JSON_NUDGE_MODALS } from '@/experiments/mcpJsonNudge/modals';
|
||||
import {
|
||||
MCP_AGENTS_VIEW,
|
||||
MCP_CLIENTS_VIEW,
|
||||
|
|
@ -104,5 +105,6 @@ export const MCPModule: FrontendModuleDescription = {
|
|||
},
|
||||
...SURFACE_MCP_TO_NEW_CLOUD_USERS_MODALS,
|
||||
...EXPOSE_ALL_WORKFLOWS_TO_MCP_MODALS,
|
||||
...MCP_JSON_NUDGE_MODALS,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4400,6 +4400,9 @@ export interface IUserSettings {
|
|||
modelName?: string;
|
||||
localGatewayDisabled?: boolean;
|
||||
};
|
||||
mcpJsonNudge?: {
|
||||
impressions: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IProcessedDataConfig {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user