feat(editor): Add inline credential setup to n8n Assistant (no-changelog) (#38684)

This commit is contained in:
oleg 2026-09-18 09:34:46 +00:00 committed by GitHub
parent 8e0c08dff2
commit b37fdc2d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 7743 additions and 804 deletions

View File

@ -197,6 +197,7 @@ export {
renderSkillCatalogPrompt,
RUNTIME_SKILL_TOOL_NAMES,
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_LINKED_FILE_GROUPS,
RUNTIME_SKILL_NAME_PATTERN,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,

View File

@ -1,5 +1,6 @@
export {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_LINKED_FILE_GROUPS,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
SKILL_LOAD_TOOL_NAME,

View File

@ -5,6 +5,7 @@ import { Tool } from '../sdk/tool';
import type { BuiltTool } from '../types';
import {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
SKILL_LOAD_TOOL_NAME,
type RuntimeSkillLinkedFile,
type RuntimeSkillLinkedFiles,
@ -15,8 +16,7 @@ import {
type RuntimeSkillContent,
} from './types';
const MAX_OUTPUT_BYTES = 64 * 1024;
const TRUNCATION_FOOTER = '\n\n[... output truncated to 64 KB ...]';
const TRUNCATION_FOOTER = `\n\n[... output truncated to ${RUNTIME_SKILL_MAX_OUTPUT_BYTES / 1024} KB ...]`;
const LINKED_FILE_GROUPS: Array<keyof RuntimeSkillLinkedFiles> = [
'references',
'templates',
@ -315,6 +315,6 @@ function envelopeValue(value: string): string {
function cap(content: string): string {
const redacted = scrubSecretsInText(content);
const bytes = Buffer.from(redacted, 'utf8');
if (bytes.byteLength <= MAX_OUTPUT_BYTES) return redacted;
return `${bytes.subarray(0, MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`;
if (bytes.byteLength <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return redacted;
return `${bytes.subarray(0, RUNTIME_SKILL_MAX_OUTPUT_BYTES).toString('utf8')}${TRUNCATION_FOOTER}`;
}

View File

@ -2,6 +2,9 @@ export const RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION = 1 as const;
export const RUNTIME_SKILL_FILE_NAME = 'SKILL.md';
/** Maximum UTF-8 bytes of instruction or linked-file content before truncation. */
export const RUNTIME_SKILL_MAX_OUTPUT_BYTES = 72 * 1024;
export const SKILL_LOAD_TOOL_NAME = 'load_skill';
export const RUNTIME_SKILL_LINKED_FILE_GROUPS = [

View File

@ -904,6 +904,11 @@ describe('agent-run-reducer', () => {
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-2', 'gmailOAuth2'));
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'notionApi'));
expect(state.agentsById['root'].latestSetupAnnouncement).toMatchObject({
workflowId: 'wf-1',
agentId: 'root',
});
const byWorkflowId = state.agentsById['root'].setupItemsByWorkflowId!;
expect(byWorkflowId['wf-1']).toHaveLength(1);
@ -918,6 +923,25 @@ describe('agent-run-reducer', () => {
expect(state.agentsById['root'].setupItemsByWorkflowId?.['wf-1']).toHaveLength(1);
expect(state.agentsById['sub-1'].setupItemsByWorkflowId).toBeUndefined();
expect(state.agentsById['root'].latestSetupAnnouncement?.agentId).toBe('sub-1');
});
it('preserves the emitting agent identity across an aliased follow-up run', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeSetupItems('run-1', 'root', 'wf-1', 'slackApi'));
reduceEvent(state, makeRunFinish('run-1', 'root', 'completed'));
reduceEvent(state, makeRunStart('run-2', 'follow-up-root'));
reduceEvent(state, makeToolCall('run-2', 'follow-up-root', 'build-2', 'build-workflow'));
reduceEvent(state, makeSetupItems('run-2', 'follow-up-root', 'wf-2', 'notionApi'));
const root = toAgentTree(state);
expect(root.latestSetupAnnouncement).toMatchObject({
workflowId: 'wf-2',
agentId: root.agentId,
});
expect(root.toolCalls).toContainEqual(
expect.objectContaining({ toolCallId: 'build-2', isLoading: true }),
);
});
it('survives a tree snapshot round trip (history restore)', () => {
@ -932,6 +956,9 @@ describe('agent-run-reducer', () => {
expect(restored?.agentsById['root'].setupItemsByWorkflowId?.['wf-1'][0]).toMatchObject({
credentialType: 'slackApi',
});
expect(restored?.agentsById['root'].latestSetupAnnouncement).toEqual(
state.agentsById['root'].latestSetupAnnouncement,
);
});
it('is preserved across a follow-up run-start when it is the only content', () => {

View File

@ -499,6 +499,11 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
// semantics: last event wins per workflowId.
const root = ensureAgent(state, state.rootAgentId);
if (root && isSafeObjectKey(event.payload.workflowId)) {
root.latestSetupAnnouncement = {
workflowId: event.payload.workflowId,
agentId: ensureAgent(state, event.agentId)?.agentId ?? event.agentId,
timestamp: eventTimestamp(event),
};
root.setupItemsByWorkflowId = {
...root.setupItemsByWorkflowId,
[event.payload.workflowId]: event.payload.items,

View File

@ -1795,6 +1795,8 @@ export interface InstanceAiAgentNode {
* regardless of which agent emitted.
*/
setupItemsByWorkflowId?: Record<string, InstanceAiSetupItem[]>;
/** Latest setup announcement, including its emitting agent and replay-stable time. */
latestSetupAnnouncement?: { workflowId: string; agentId: string; timestamp: string };
result?: string;
error?: string;
errorDetails?: {

View File

@ -39,6 +39,9 @@ Use this section when the system prompt describes the persistent setup panel,
setup returns `announced: true`, or the current user input contains
`<workflow-test-request>`. Otherwise, keep the setup card flow below.
- Setup requirements can appear while the workflow is being built. The user can
complete them immediately. Refer to the "setup panel" without a position or
a claim that setup must wait until the build finishes.
- Verify what the build can simulate before asking the user to finish setup.
Missing credentials do not prevent this verification. Report which outputs
were simulated. A simulated result does not prove a live connection.

View File

@ -516,7 +516,9 @@ When `nodes(action="explore-resources")` returns no results for a required
resource:
1. If the resource can be represented as a user choice, use
`placeholder('Select <resource>')` and let setup collect it after the build.
`placeholder('Select <resource>')` and let setup collect it. When the persistent
setup panel is enabled, the user can fill announced requirements during the
build. Do not tell them to wait until the build finishes.
2. If the user explicitly asked you to create the resource and the node type
definition has a safe create operation, build and verify that
resource-creation workflow as part of the requested work.

View File

@ -18,7 +18,8 @@ describe('getSystemPrompt — setup panel', () => {
const prompt = getSystemPrompt({ setupPanelEnabled: true });
expect(prompt).not.toContain('opens the inline setup card');
expect(prompt).toContain('setup panel next to the chat');
expect(prompt).toContain('setup panel while the workflow is being built');
expect(prompt).not.toContain('next to the chat');
expect(prompt).toContain('<workflow-setup-state>');
expect(prompt).toContain('end your turn');
expect(prompt).toContain('When the result has `announced: true`');

View File

@ -210,7 +210,7 @@ If the user asks for a blocked operation, explain that the instance is in read-o
*/
function getCredentialSetupBullet(setupPanelEnabled?: boolean): string {
if (setupPanelEnabled) {
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. When the result has `announced: true`, the setup panel next to the chat lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available. Requirements can appear in the setup panel while the workflow is being built. The user can complete them immediately. Do not describe setup as happening only after the build. When the result has `announced: true`, the setup panel lists the remaining credentials and parameters. Summarize that result, report any validation warnings, and end your turn. Other results need their returned guidance: correct validation errors, respect denials and skipped items, and wait for requested destination approvals. Explicit credential replacement and an already-open setup card keep their card flow, including apply and test-trigger results. Do not treat a resumed card as a panel announcement. Each new user turn carries a `<workflow-setup-state>` block with current configuration; trust it over older tool results. Configuration alone does not prove successful testing. Use `credentials(action="setup")` when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor, and never ask the user to paste secrets into chat.';
}
return '**Credential setup** uses `workflows(action="setup")` when a workflowId is available — it opens the inline setup card in the n8n Assistant panel and handles credentials, parameters, and triggers in one step. Use `credentials(action="setup")` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in `nodesStillNeedingSetup` carries `parameterIssues`, the connected credential can\'t reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn\'t work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under `skippedByUser` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.';
}

View File

@ -1,4 +1,5 @@
import {
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
createSkillLoadTool,
type RuntimeSkillLinkedFiles,
@ -356,7 +357,6 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
});
it('warns when materialized skill files exceed the load_skill output limit', async () => {
const runtimeSkillMaxOutputBytes = 64 * 1024;
const source: RuntimeSkillSource = {
registry: {
schemaVersion: RUNTIME_SKILL_REGISTRY_SCHEMA_VERSION,
@ -376,7 +376,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
id: 'large-skill',
name: 'large-skill',
description: 'Large skill',
instructions: 'x'.repeat(runtimeSkillMaxOutputBytes + 1),
instructions: 'x'.repeat(RUNTIME_SKILL_MAX_OUTPUT_BYTES + 1),
}),
};
const { workspace } = createMockWorkspace();
@ -399,7 +399,7 @@ describe('materializeRuntimeSkillsIntoWorkspace', () => {
const [message, meta] = limitWarnCall!;
expect(message).toBe('Runtime skill file exceeds load_skill output limit');
expect(meta?.skill).toBe('large-skill');
expect(meta?.maxBytes).toBe(runtimeSkillMaxOutputBytes);
expect(meta?.maxBytes).toBe(RUNTIME_SKILL_MAX_OUTPUT_BYTES);
});
});

View File

@ -1,5 +1,6 @@
import {
RUNTIME_SKILL_FILE_NAME,
RUNTIME_SKILL_MAX_OUTPUT_BYTES,
type RuntimeSkillContent,
type RuntimeSkillDependenciesContract,
type RuntimeSkillInterfaceContract,
@ -91,7 +92,6 @@ const LINKED_FILE_GROUPS = [
const N8N_SKILL_DIR_TEMPLATE = '$' + '{N8N_SKILL_DIR}';
const N8N_SKILLS_DIR_TEMPLATE = '$' + '{N8N_SKILLS_DIR}';
const N8N_WORKSPACE_DIR_TEMPLATE = '$' + '{N8N_WORKSPACE_DIR}';
const LOAD_SKILL_OUTPUT_LIMIT_BYTES = 64 * 1024;
function isNonEmptyRecord(value: Record<string, unknown>): boolean {
return Object.keys(value).length > 0;
@ -410,13 +410,13 @@ function warnIfExceedsLoadSkillLimit(
content: string,
): void {
const bytes = Buffer.byteLength(content, 'utf8');
if (bytes <= LOAD_SKILL_OUTPUT_LIMIT_BYTES) return;
if (bytes <= RUNTIME_SKILL_MAX_OUTPUT_BYTES) return;
logger.warn('Runtime skill file exceeds load_skill output limit', {
skill: entry.name,
path: filePath,
bytes,
maxBytes: LOAD_SKILL_OUTPUT_LIMIT_BYTES,
maxBytes: RUNTIME_SKILL_MAX_OUTPUT_BYTES,
});
}

View File

@ -804,7 +804,7 @@ async function announceSetupItems(
workflowId,
credentials: existingByType,
message:
`Listed ${typeNames} in the setup panel next to the chat. No card is open and nothing is waiting on ` +
`Listed ${typeNames} in the setup panel. No card is open and nothing is waiting on ` +
'you: continue the build. The user can connect these at any time while you work — the build ' +
'attaches a matching stored credential automatically and the panel tracks what remains. Do not ' +
'ask the user to connect them now, do not describe a card, and do not call setup again for these ' +

View File

@ -1493,7 +1493,7 @@ async function resolveUnverifiedPublishDisclosure(
}
const SETUP_PANEL_ANNOUNCED_GUIDANCE =
'The setup panel next to the chat now lists what this workflow still needs (`open`); nothing is ' +
'The setup panel now lists what this workflow still needs (`open`); nothing is ' +
'waiting on you and no card is open. Finish your turn now: tell the user in one or two sentences ' +
'what to configure in the panel — name the services and any values — then stop. Do not call setup ' +
'again for this workflow, do not call `credentials(action="setup")`, and do not tell the user to ' +

View File

@ -270,7 +270,7 @@ export function formatWorkflowSetupStateNote(
}));
return [
'Setup state of the workflows this conversation built, recomputed just now from the saved ' +
'workflows and the credentials in this project. The setup panel next to the chat shows the ' +
'workflows and the credentials in this project. The setup panel shows the ' +
'current checklist and the user completes items there. This observation does not test connections ' +
'or resource availability. A configured item is not proof of a successful connection test or ' +
'workflow execution. Never ask ' +

View File

@ -553,7 +553,7 @@ describe('formatWorkflowLoopGuidance — setup panel', () => {
expect(result).toContain('workflows(action="setup")');
expect(result).toContain('wf-123');
expect(result).toContain('setup panel next to the chat');
expect(result).toContain('setup panel');
expect(result).toContain('end your turn');
expect(result).toContain('When the result has `announced: true`');
expect(result).toContain('Otherwise follow the returned guidance');

View File

@ -97,7 +97,7 @@ export function formatWorkflowLoopGuidance(
return (
`${claimLead} It still uses temporary mock data. ` +
`Call \`workflows(action="setup")\` with workflowId "${action.workflowId ?? 'unknown'}" once: ` +
'it lists the remaining credentials and values in the setup panel next to the chat and returns them to you. ' +
'it lists the remaining credentials and values in the setup panel and returns them to you. ' +
'When the result has `announced: true`, summarize it, report any validation warnings, and end your turn. ' +
'Otherwise follow the returned guidance for validation errors, approvals, skipped items, or an existing setup card. ' +
'Do not call `credentials(action="setup")` or `apply-workflow-credentials`, and do not tell the user to open the editor or canvas.'

View File

@ -3,6 +3,32 @@ import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
export const CREDENTIALS_TELEMETRY = defineTelemetryEvents({
USER_STARTED_CREDENTIAL_CONNECTION: {
name: 'User started credential connection',
description:
'The user started a connection attempt or selected an existing credential in the setup panel. Includes retries.',
properties: z.object({
source: z.literal('instance_ai_setup_panel'),
workflow_id: z.string(),
thread_id: z.string(),
credential_type: z.string(),
method: z.enum(['oauth', 'api_key', 'gateway', 'advanced', 'existing']),
}),
},
USER_COMPLETED_CREDENTIAL_CONNECTION: {
name: 'User completed credential connection',
description:
'A setup-panel credential was selected or connected and its workflow binding was applied or queued. This does not report workflow validation.',
properties: z.object({
source: z.literal('instance_ai_setup_panel'),
workflow_id: z.string(),
thread_id: z.string(),
credential_type: z.string(),
credential_id: z.string().nullable(),
method: z.enum(['oauth', 'api_key', 'gateway', 'advanced', 'existing']),
binding_state: z.enum(['applied', 'noop', 'queued']),
}),
},
USER_PROBED_CREDENTIAL: {
name: 'User probed credential',
description:

View File

@ -52,6 +52,47 @@ const threadActionSource = z.enum([
const prefillType = z.enum([...INSTANCE_AI_PREFILL_TYPES, INSTANCE_AI_PREFILL_TYPE_FALLBACK]);
export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
SETUP_PANEL_STATE_OBSERVED: {
name: 'AI Assistant setup panel state observed',
description:
'The setup panel observed a new requirement snapshot. Also fires when no setup is needed, including workflows whose credentials were already connected.',
properties: z.object({
workflow_id: z.string(),
thread_id: z.string(),
credential_count: z.number(),
pending_credential_count: z.number(),
pending_parameter_count: z.number(),
already_connected_count: z.number(),
}),
},
SETUP_PANEL_ITEM_SHOWN: {
name: 'AI Assistant setup panel item shown',
description:
'A setup checklist row became visible. Reopening the same row within the mounted panel does not emit another event.',
properties: z.object({
workflow_id: z.string(),
thread_id: z.string(),
kind: z.enum(['credential', 'parameters', 'details']),
credential_type: z.string().optional(),
parameter_count: z.number(),
}),
},
SETUP_PANEL_DISMISSED: {
name: 'AI Assistant setup panel dismissed',
description:
'A visible setup panel closed. The reason separates navigation and removed requirements from a finished execution or an explicit dismissal.',
properties: z.object({
workflow_id: z.string(),
thread_id: z.string(),
reason: z.enum([
'navigation',
'items_removed',
'execution_succeeded',
'execution_finished',
'user_dismissed',
]),
}),
},
USER_CLICKED_AI_CREDIT_BALANCE: {
name: 'User clicked AI credit balance',
description:

View File

@ -3,6 +3,16 @@ import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
export const WORKFLOW_TELEMETRY = defineTelemetryEvents({
USER_REQUESTED_WORKFLOW_TEST: {
name: 'User requested workflow test',
description:
'The user started a manual workflow test from the AI Assistant setup panel. This event does not report execution success.',
properties: z.object({
source: z.literal('instance_ai_setup_panel'),
workflow_id: z.string(),
thread_id: z.string().optional(),
}),
},
MULTIPLE_NODES_SELECTED: {
name: 'User selected multiple nodes',
description:

View File

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nSetupConnection from './SetupConnection.vue';
const meta = {
title: 'Areas/Assistant/SetupConnection',
component: N8nSetupConnection,
args: {
connected: false,
actionLabel: 'Connect',
actions: [{ id: 'advanced', label: 'Advanced setup' }],
},
} satisfies Meta<typeof N8nSetupConnection>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Connect: Story = {};
export const Connecting: Story = { args: { loading: true } };
export const Connected: Story = {
args: {
connected: true,
valueLabel: 'Account',
value: 'demo@example.com',
actions: [
{ id: 'switch', label: 'Switch account' },
{ id: 'edit', label: 'Edit credential' },
],
},
};

View File

@ -0,0 +1,62 @@
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/vue';
import SetupConnection from './SetupConnection.vue';
describe('SetupConnection', () => {
it('keeps Advanced setup available while required fields disable the primary action', async () => {
const { getByRole, getAllByRole, findByRole, emitted, rerender } = render(SetupConnection, {
props: {
connected: false,
actionLabel: 'Connect',
actionDisabled: true,
actions: [{ id: 'advanced', label: 'Advanced setup' }],
},
});
expect(getByRole('button', { name: 'Connect' })).toBeDisabled();
expect(getAllByRole('button')).toEqual([
getByRole('button', { name: 'More options' }),
getByRole('button', { name: 'Connect' }),
]);
await userEvent.click(getByRole('button', { name: 'More options' }));
await userEvent.click(await findByRole('menuitem', { name: 'Advanced setup' }));
expect(emitted('select')).toEqual([['advanced']]);
expect(emitted('action')).toBeUndefined();
await rerender({ actionDisabled: false });
await userEvent.click(getByRole('button', { name: 'Connect' }));
expect(emitted('action')).toHaveLength(1);
});
it('shows a saved connection and opens its pencil menu without starting a connection', async () => {
const { getByRole, getByText, findByRole, queryByRole, emitted } = render(SetupConnection, {
props: {
connected: true,
value: 'user@example.com',
valueLabel: 'Account',
actionLabel: 'Connect',
actions: [{ id: 'replace', label: 'Switch account' }],
},
});
expect(getByText('user@example.com')).toBeVisible();
expect(queryByRole('button', { name: 'Connect' })).toBeNull();
await userEvent.click(getByRole('button', { name: 'Change connection' }));
await userEvent.click(await findByRole('menuitem', { name: 'Switch account' }));
expect(emitted('select')).toEqual([['replace']]);
expect(emitted('action')).toBeUndefined();
});
it.each([false, true])(
'disables connection controls during a request, connected: %s',
(connected) => {
const { getAllByRole } = render(SetupConnection, {
props: {
connected,
loading: true,
actionLabel: 'Connect',
actions: [{ id: 'edit', label: 'Edit' }],
},
});
for (const button of getAllByRole('button')) expect(button).toBeDisabled();
},
);
});

View File

@ -0,0 +1,23 @@
import type { ButtonProps } from '../../types/button';
import type { DropdownMenuItemProps } from '../N8nDropdownMenu/DropdownMenu.types';
export interface SetupConnectionProps {
/** Whether the host has a saved connection to display. */
connected: boolean;
/** Label for the saved account or authentication value. */
valueLabel?: string;
/** Public account identifier, credential name, or masked key. Never pass a secret. */
value?: string;
/** Label for the primary connect or save action. */
actionLabel: string;
/** Button treatment for the connect or save action. */
actionVariant?: ButtonProps['variant'];
/** Secondary actions for the current connection method. */
actions: DropdownMenuItemProps[];
/** Whether the primary action is unavailable, such as for an incomplete form. */
actionDisabled?: boolean;
/** Whether a connection attempt or save is in progress. */
loading?: boolean;
/** Whether the connection and its secondary actions are unavailable. */
disabled?: boolean;
}

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import type { SetupConnectionProps } from './SetupConnection.types';
import N8nDropdownMenu from '../N8nDropdownMenu/DropdownMenu.vue';
import N8nIcon from '../N8nIcon';
defineProps<SetupConnectionProps>();
const emit = defineEmits<{
action: [];
select: [id: string];
}>();
const { t } = useI18n();
</script>
<template>
<div :class="$style.connection">
<template v-if="connected">
<div :class="$style.field">
<span v-if="valueLabel" :class="$style.label">{{ valueLabel }}</span>
<div :class="$style.saved">
<N8nIcon icon="circle-check" size="small" :class="$style.success" />
<span :class="$style.value" :title="value">{{ value || t('setupPanel.connected') }}</span>
<N8nDropdownMenu
v-if="actions.length"
:items="actions"
:disabled="disabled || loading"
:modal="false"
placement="bottom-end"
@select="emit('select', $event)"
>
<template #trigger>
<N8nButton
variant="ghost"
size="xsmall"
icon-only
:disabled="disabled || loading"
:aria-label="t('setupPanel.changeConnection')"
>
<N8nIcon icon="pencil" size="small" />
</N8nButton>
</template>
</N8nDropdownMenu>
</div>
<span :class="$style.success" role="status">{{ t('setupPanel.credentialSelected') }}</span>
</div>
</template>
<template v-else>
<slot />
<div :class="$style.footer">
<div v-if="$slots['action-leading']" :class="$style.actionLeading">
<slot name="action-leading" />
</div>
<div :class="$style.actions">
<N8nDropdownMenu
v-if="actions.length"
:items="actions"
:disabled="disabled || loading"
:modal="false"
placement="bottom-end"
@select="emit('select', $event)"
>
<template #trigger>
<N8nButton
size="small"
variant="subtle"
icon-only
:disabled="disabled || loading"
:aria-label="t('setupPanel.moreOptions')"
>
<N8nIcon icon="ellipsis" size="small" />
</N8nButton>
</template>
</N8nDropdownMenu>
<N8nButton
size="small"
:variant="actionVariant"
:disabled="disabled || actionDisabled || loading"
:loading="loading"
@click="emit('action')"
>
{{ actionLabel }}
</N8nButton>
</div>
</div>
<slot name="footer" />
</template>
</div>
</template>
<style lang="scss" module>
.connection {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
font-size: var(--font-size--xs);
line-height: var(--line-height--md);
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.label {
color: var(--text-color--subtle);
}
.saved {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
border: var(--border);
border-radius: var(--radius);
min-height: var(--height--md);
padding: var(--spacing--5xs) var(--spacing--2xs);
background: var(--background--subtle);
}
.value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-color);
}
.actions {
display: flex;
flex-shrink: 0;
gap: var(--spacing--3xs);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing--xs);
}
.actionLeading {
flex: 1;
min-width: 0;
color: var(--text-color--subtle);
}
.success {
color: var(--text-color--success);
font-size: var(--font-size--2xs);
}
</style>

View File

@ -0,0 +1,356 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import N8nSelect from '../../v2/components/Select/Select.vue';
import N8nButton from '../N8nButton';
import N8nCopyInput from '../N8nCopyInput';
import N8nIcon from '../N8nIcon';
import N8nInput from '../N8nInput';
import N8nSegmentControl from '../N8nSegmentControl';
import N8nText from '../N8nText';
import N8nSetupPanel from './SetupPanel.vue';
import N8nSetupConnection from '../N8nSetupConnection/SetupConnection.vue';
const meta = {
title: 'Areas/Assistant/SetupPanel',
component: N8nSetupPanel,
parameters: { layout: 'centered' },
} satisfies Meta<typeof N8nSetupPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Checklist: Story = {
args: {
items: [
{ id: 'granola', title: 'Granola', completed: true },
{ id: 'slack', title: 'Slack', subtitle: 'Add connection, channel', completed: false },
{ id: 'details', title: 'Details', subtitle: 'Add summary length', completed: false },
],
},
render: (args) => ({
components: { N8nSetupPanel, N8nIcon, N8nInput },
setup: () => ({ args, active: ref<string>() }),
template: `
<div style="width: min(28rem, 90vw); min-height: 75vh; display: flex; flex-direction: column; justify-content: end; gap: var(--spacing--xs)">
<N8nSetupPanel v-bind="args" v-model:active-item-id="active">
<template #icon><N8nIcon icon="plug" size="small" /></template>
<template #detail="{ item }">{{ item.title }}</template>
</N8nSetupPanel>
<N8nInput type="textarea" aria-label="Chat" placeholder="Ask anything..." />
</div>
`,
}),
};
export const SetupComplete: Story = {
...Checklist,
args: {
items: [
{ id: 'slack', title: 'Slack', completed: true },
{ id: 'details', title: 'Details', completed: true },
],
status: 'complete',
},
};
export const Validating: Story = {
...SetupComplete,
args: { ...SetupComplete.args, status: 'validating' },
};
export const Executing: Story = {
...SetupComplete,
args: { ...SetupComplete.args, status: 'executing' },
};
export const ExecuteUnavailable: Story = {
...SetupComplete,
args: { ...SetupComplete.args, executeDisabled: true },
};
type Example = {
service: string;
method: 'oauth' | 'key' | 'selfHosted' | 'gateway' | 'details';
connected?: boolean;
confirmed?: boolean;
field?: string;
custom?: boolean;
useOwnKey?: boolean;
/** Show the full completion flow instead of a static state example. */
interactive?: boolean;
};
function example(state: Example): Story {
return {
args: { items: [] },
render: () => ({
components: {
N8nSetupPanel,
N8nSetupConnection,
N8nButton,
N8nCopyInput,
N8nInput,
N8nIcon,
N8nSegmentControl,
N8nText,
N8nSelect,
},
setup() {
const active = ref<string | undefined>(
state.method === 'oauth' && !state.connected ? undefined : 'service',
);
const connected = ref(state.connected ?? false);
const confirmed = ref(state.confirmed ?? false);
const mode = ref(state.useOwnKey ? 'key' : 'credits');
const key = ref('');
const clientId = ref('');
const clientSecret = ref('');
const fieldValue = ref('suggested');
const savedField = ref(state.confirmed ? 'suggested' : undefined);
const menuFeedback = ref('');
const completed = computed(
() =>
(state.method === 'details' || connected.value) && (!state.field || confirmed.value),
);
const reducedMotion = usePreferredReducedMotion();
const { start: returnToChecklist, stop: cancelReturn } = useTimeoutFn(
() => {
active.value = undefined;
},
computed(() => (reducedMotion.value === 'reduce' ? 0 : 650)),
{ immediate: false },
);
watch([active, fieldValue], cancelReturn);
function confirmField() {
confirmed.value = true;
savedField.value = fieldValue.value;
if (state.interactive) returnToChecklist();
}
const items = computed(() => [
{
id: 'service',
title: state.service,
completed: completed.value,
hasAction: state.method === 'oauth' && !connected.value,
subtitle: `Add ${!connected.value && state.method !== 'details' ? 'connection' : state.field}`,
},
]);
const actionLabel = computed(() => {
if (state.method === 'gateway') return mode.value === 'credits' ? 'Use credits' : 'Save';
return state.method === 'selfHosted'
? 'Save and sign in'
: state.method === 'key'
? 'Save'
: 'Connect';
});
const actions = computed(() => {
if (!connected.value && state.method === 'gateway' && mode.value === 'credits') return [];
if (!connected.value) return [{ id: 'advanced', label: 'Advanced setup' }];
if (state.method === 'gateway' && mode.value === 'credits')
return [
{ id: 'replace', label: 'Use my API key' },
{ id: 'manage', label: 'Manage Gateway credits' },
];
if (state.custom)
return [
{ id: 'edit', label: 'Edit credential' },
{ id: 'switch', label: 'Connect with OAuth instead' },
];
return [
{
id: 'replace',
label:
state.method === 'key' || state.method === 'gateway'
? 'Create new credential'
: 'Switch account',
},
{ id: 'edit', label: 'Edit credential' },
];
});
const value = computed(() => {
if (state.method === 'gateway' && mode.value === 'credits') return 'Gateway credits';
if (state.custom || state.method === 'key' || state.method === 'gateway')
return '••••••••';
return 'demo@example.com';
});
return {
state,
menuFeedback,
confirmField,
status: computed(() =>
state.interactive
? completed.value && savedField.value === fieldValue.value
? 'complete'
: 'incomplete'
: undefined,
),
active,
connected,
confirmed,
mode,
key,
clientId,
clientSecret,
fieldValue,
savedField,
items,
actions,
actionLabel,
value,
valueLabel: state.method === 'key' || state.method === 'gateway' ? 'API key' : 'Account',
actionDisabled: computed(() =>
state.method === 'selfHosted'
? !clientId.value || !clientSecret.value
: (state.method === 'key' || (state.method === 'gateway' && mode.value === 'key')) &&
!key.value,
),
onMenuAction: (id: string) => {
menuFeedback.value = actions.value.find((action) => action.id === id)?.label ?? '';
if (id === 'replace' || id === 'switch') {
connected.value = false;
mode.value = 'key';
}
},
modeOptions: [
{ value: 'credits', label: 'Gateway credits' },
{ value: 'key', label: 'Use my API key' },
],
fieldOptions: [
{
value: 'suggested',
label: state.field === 'channel' ? '#design-team' : 'Suggested value',
},
{ value: 'other', label: 'Another value' },
],
};
},
template: `
<div style="width: min(28rem, 90vw); min-height: 75vh; display: flex; flex-direction: column; justify-content: end; gap: var(--spacing--xs)">
<N8nSetupPanel :items="items" :status="status" v-model:active-item-id="active">
<template #icon><N8nIcon icon="plug" size="small" /></template>
<template #action>
<N8nButton size="small" variant="subtle" @click="connected = true; active = state.field ? 'service' : undefined">Connect</N8nButton>
</template>
<template #detail>
<div style="display: flex; flex-direction: column; gap: var(--spacing--xs)">
<N8nText v-if="menuFeedback" role="status" size="small">Selected: {{ menuFeedback }}</N8nText>
<N8nSetupConnection
v-if="state.method !== 'details'"
:connected="connected" :value="value" :value-label="valueLabel"
:action-label="actionLabel" :actions="actions"
:action-disabled="actionDisabled"
@action="connected = true" @select="onMenuAction"
>
<N8nSegmentControl v-if="state.method === 'gateway'" v-model="mode" :options="modeOptions" />
<template v-if="state.method === 'gateway' && mode === 'credits'">
<N8nText size="small" style="color: var(--text-color--subtle)">Use Gateway credits without adding an API key.</N8nText>
</template>
<template v-if="state.method === 'gateway' && mode === 'credits'" #action-leading>
<N8nText step="xs">$2.00 left</N8nText>
</template>
<label v-if="state.method === 'key' || (state.method === 'gateway' && mode === 'key')">
<N8nInput v-model="key" size="small" aria-label="API key" placeholder="API key" type="password" autocomplete="new-password" />
</label>
<template v-if="state.method === 'selfHosted'">
<label style="display: flex; flex-direction: column; gap: var(--spacing--3xs); color: var(--text-color--subtle)">
Redirect URL
<N8nCopyInput size="small" style="font-family: var(--font-family--monospace); --input--font-size: var(--font-size--2xs)" value="https://example.com/rest/oauth2-credential/callback" />
</label>
<N8nInput v-model="clientId" size="small" aria-label="Client ID" placeholder="Client ID" />
<N8nInput v-model="clientSecret" size="small" aria-label="Client Secret" placeholder="Client Secret" type="password" autocomplete="new-password" />
</template>
</N8nSetupConnection>
<template v-if="state.field && (connected || state.method === 'details')">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--spacing--2xs); align-items: end">
<div style="display: flex; flex-direction: column; gap: var(--spacing--2xs)">
<span>{{ state.field }}</span>
<N8nSelect v-model="fieldValue" :items="fieldOptions" :aria-label="state.field" />
</div>
<N8nButton size="medium" :disabled="savedField === fieldValue" @click="confirmField">{{ confirmed ? 'Update' : 'Confirm' }}</N8nButton>
</div>
</template>
</div>
</template>
</N8nSetupPanel>
<N8nInput type="textarea" aria-label="Chat" placeholder="Ask anything..." />
</div>
`,
}),
};
}
export const ManagedOAuth = example({ service: 'Granola', method: 'oauth' });
export const ManagedOAuthConnected = example({
service: 'Granola',
method: 'oauth',
connected: true,
});
export const AdvancedSetupConnected = example({
service: 'Granola',
method: 'oauth',
connected: true,
custom: true,
});
export const ManagedOAuthWithDetails = example({
service: 'Slack',
method: 'oauth',
field: 'channel',
});
export const ConnectedWithDetailsPending = example({
service: 'Slack',
method: 'oauth',
connected: true,
field: 'channel',
});
export const ServiceComplete = example({
service: 'Slack',
method: 'oauth',
connected: true,
field: 'channel',
confirmed: true,
});
export const ApiKey = example({ service: 'Clearbit', method: 'key' });
export const ApiKeyConnected = example({ service: 'Clearbit', method: 'key', connected: true });
export const GatewayCredits = example({ service: 'OpenAI', method: 'gateway' });
export const StateTransitions = example({
service: 'Example service',
method: 'gateway',
field: 'Notification channel',
interactive: true,
});
export const GatewayOwnKey = example({ service: 'OpenAI', method: 'gateway', useOwnKey: true });
export const GatewayCreditsConnected = example({
service: 'OpenAI',
method: 'gateway',
connected: true,
});
export const GatewayOwnKeyConnected = example({
service: 'OpenAI',
method: 'gateway',
connected: true,
useOwnKey: true,
});
export const SelfHostedOAuth = example({
service: 'LinkedIn',
method: 'selfHosted',
field: 'Post as',
});
export const SelfHostedDetailsPending = example({
service: 'LinkedIn',
method: 'selfHosted',
connected: true,
field: 'Post as',
});
export const SelfHostedComplete = example({
service: 'LinkedIn',
method: 'selfHosted',
connected: true,
field: 'Post as',
confirmed: true,
});
export const Details = example({ service: 'Details', method: 'details', field: 'Summary length' });
export const DetailsComplete = example({
service: 'Details',
method: 'details',
field: 'Summary length',
confirmed: true,
});

View File

@ -0,0 +1,239 @@
import userEvent from '@testing-library/user-event';
import { fireEvent, render, waitFor } from '@testing-library/vue';
import { nextTick, ref } from 'vue';
import SetupPanel from './SetupPanel.vue';
describe('SetupPanel', () => {
it('replaces the service icon on completion without showing a progress count', async () => {
const items = [
{ id: 'slack', title: 'Slack', completed: false },
{ id: 'gmail', title: 'Gmail', completed: true },
];
const { getByRole, queryByText, rerender } = render(SetupPanel, {
props: { items, activeItemId: 'slack' },
slots: { icon: '<span data-test-id="service-icon" />' },
});
const detail = getByRole('dialog', { name: 'Slack' });
expect(detail.querySelector('[data-test-id="service-icon"]')).toBeVisible();
await rerender({ items: items.map((item) => ({ ...item, completed: true })) });
expect(detail.querySelector('[data-test-id="service-icon"]')).toBeNull();
expect(detail.querySelector('[data-icon="status-completed"]')).toBeVisible();
await rerender({ activeItemId: undefined, status: 'complete' });
expect(getByRole('button', { name: 'Setup complete' })).toBeVisible();
expect(getByRole('button', { name: 'Execute' })).toBeVisible();
expect(queryByText(/\d+\s*(?:of|\/)\s*\d+/i)).toBeNull();
});
it('retains outgoing content while making the closing detail inaccessible', async () => {
const activeItemId = ref<string | undefined>('slack');
const { getByRole, getByTestId, queryByRole, unmount } = render(
{
components: { SetupPanel },
setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }),
template: `<SetupPanel :items="items" v-model:active-item-id="activeItemId">
<template #detail><span v-if="activeItemId">Connection form</span></template>
</SetupPanel>`,
},
{ global: { stubs: { transition: false } } },
);
const overlay = getByTestId('setup-panel-overlay');
overlay.style.transitionDuration = '1s';
overlay.style.transitionDelay = '0s';
await fireEvent.click(getByRole('button', { name: 'Back to setup checklist' }));
await nextTick();
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveTextContent('Connection form');
expect(overlay).toHaveAttribute('inert');
expect(overlay).toHaveAttribute('aria-hidden', 'true');
expect(queryByRole('dialog')).toBeNull();
unmount();
});
it('shows a compact completion bar and lets users review completed items', async () => {
const { getByRole, queryByRole, emitted } = render(SetupPanel, {
props: { items: [{ id: 'slack', title: 'Slack', completed: true }], status: 'complete' },
});
expect(queryByRole('button', { name: 'Slack Complete' })).toBeNull();
await userEvent.click(getByRole('button', { name: 'Setup complete' }));
expect(getByRole('button', { name: 'Slack Complete' })).toBeVisible();
await userEvent.click(getByRole('button', { name: 'Execute' }));
expect(emitted('execute')).toHaveLength(1);
});
it.each(['validating', 'executing'] as const)('shows %s without an Execute action', (status) => {
const { getByRole, queryByRole } = render(SetupPanel, {
props: { items: [{ id: 'slack', title: 'Slack', completed: true }], status },
});
expect(getByRole('status')).toHaveTextContent(
status === 'validating' ? 'Validating…' : 'Executing…',
);
expect(queryByRole('button', { name: 'Execute' })).toBeNull();
});
it('opens the overlay by keyboard and returns focus to the selected row', async () => {
const activeItemId = ref<string>();
const { getByRole, getByText } = render({
components: { SetupPanel },
setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }),
template: `<SetupPanel :items="items" v-model:active-item-id="activeItemId">
<template #detail>Connection form</template>
</SetupPanel>`,
});
getByRole('button', { name: 'Slack' }).focus();
await userEvent.keyboard('{Enter}');
expect(getByText('Connection form')).toBeVisible();
const back = getByRole('button', { name: 'Back to setup checklist' });
await waitFor(() => expect(back).toHaveFocus());
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(getByRole('button', { name: 'Slack' })).toHaveFocus());
});
it('keeps the checklist mounted but inaccessible while the overlay is open', async () => {
const activeItemId = ref<string>();
const { getByRole, getByTestId, queryByRole } = render({
components: { SetupPanel },
setup: () => ({ activeItemId, items: [{ id: 'slack', title: 'Slack', completed: false }] }),
template: `<SetupPanel :items="items" v-model:active-item-id="activeItemId">
<template #detail><input aria-label="Channel" /></template>
</SetupPanel><input aria-label="Chat" />`,
});
await userEvent.click(getByRole('button', { name: 'Slack' }));
expect(getByRole('dialog', { name: 'Slack' })).toBeVisible();
expect(getByTestId('setup-panel-row').closest('ul')).toHaveAttribute('inert');
expect(queryByRole('button', { name: 'Slack' })).toBeNull();
await userEvent.type(getByRole('textbox', { name: 'Channel' }), '#team');
await userEvent.click(getByRole('textbox', { name: 'Chat' }));
expect(getByRole('textbox', { name: 'Channel' })).toHaveValue('#team');
await userEvent.click(getByRole('textbox', { name: 'Channel' }));
await userEvent.keyboard('{Escape}');
expect(queryByRole('dialog')).toBeNull();
await waitFor(() => expect(getByRole('button', { name: 'Slack' })).toHaveFocus());
});
it('renders a direct connection action without opening the overlay', async () => {
const connect = vi.fn();
const { getByRole, queryByRole, emitted } = render(SetupPanel, {
props: { items: [{ id: 'slack', title: 'Slack', completed: false, hasAction: true }] },
slots: {
action: {
template: '<button @click="connect">Connect</button>',
setup: () => ({ connect }),
},
},
});
expect(getByRole('group', { name: 'Slack' })).toBeVisible();
await userEvent.click(getByRole('button', { name: 'Connect' }));
expect(connect).toHaveBeenCalledOnce();
expect(queryByRole('dialog')).toBeNull();
expect(emitted('update:activeItemId')).toBeUndefined();
});
it('exposes completion and prevents unavailable rows from opening', async () => {
const { getByRole, emitted } = render(SetupPanel, {
props: {
items: [
{ id: 'slack', title: 'Slack', completed: true },
{ id: 'details', title: 'Details', completed: false, disabled: true },
],
},
});
expect(getByRole('button', { name: 'Slack Complete' })).toBeEnabled();
expect(
getByRole('button', { name: 'Slack Complete' }).querySelector(
'[data-icon="status-completed"]',
),
).toBeVisible();
const details = getByRole('button', { name: 'Details' });
expect(details).toBeDisabled();
await userEvent.click(details);
expect(emitted('update:activeItemId')).toBeUndefined();
});
it('returns to the remaining checklist when the active requirement disappears', async () => {
const { rerender, queryByText, getByRole, emitted } = render(SetupPanel, {
props: { items: [{ id: 'slack', title: 'Slack', completed: false }], activeItemId: 'slack' },
slots: { detail: 'Connection form' },
});
await rerender({ items: [{ id: 'details', title: 'Details', completed: false }] });
expect(queryByText('Connection form')).toBeNull();
expect(getByRole('button', { name: 'Details' })).toBeVisible();
await waitFor(() => expect(getByRole('button', { name: 'Details' })).toHaveFocus());
expect(emitted('update:activeItemId')).toContainEqual([undefined]);
await rerender({ items: [] });
expect(queryByText('Details')).toBeNull();
});
it.each(['removed', 'disabled', 'empty'])(
'emits one close event when the active item becomes %s',
async (change) => {
const items = [
{ id: 'slack', title: 'Slack', completed: false },
{ id: 'details', title: 'Details', completed: false },
];
const { getByTestId, rerender, emitted } = render(SetupPanel, {
props: { items, activeItemId: 'slack' },
global: { stubs: { transition: false } },
});
const overlay = getByTestId('setup-panel-overlay');
overlay.style.transitionDuration = '0.05s';
overlay.style.transitionDelay = '0s';
await rerender({
items:
change === 'empty'
? []
: change === 'removed'
? items.slice(1)
: items.map((item) => ({ ...item, disabled: item.id === 'slack' })),
});
const closeEventsBeforeLeave = emitted('detailClosed')?.length ?? 0;
await waitFor(() => expect(overlay).not.toBeInTheDocument());
expect(emitted('detailClosed')).toEqual([[]]);
if (change !== 'empty') expect(closeEventsBeforeLeave).toBe(0);
},
);
it.each(['missing', 'disabled', 'empty'])(
'clears the %s initial selection without opening it after the items change',
async (initialState) => {
const activeItemId = ref<string | undefined>('slack');
const items = ref(
initialState === 'empty'
? []
: [
{
id: initialState === 'missing' ? 'details' : 'slack',
title: 'Slack',
completed: false,
disabled: initialState === 'disabled',
},
],
);
const onDetailClosed = vi.fn();
const { queryByRole } = render({
components: { SetupPanel },
setup: () => ({ activeItemId, items, onDetailClosed }),
template:
'<SetupPanel :items="items" v-model:active-item-id="activeItemId" @detail-closed="onDetailClosed" />',
});
expect(activeItemId.value).toBeUndefined();
items.value = [{ id: 'slack', title: 'Slack', completed: false, disabled: false }];
await nextTick();
expect(queryByRole('dialog')).toBeNull();
expect(onDetailClosed).not.toHaveBeenCalled();
},
);
it('does not open a selected detail when mounted during execution', () => {
const { queryByRole, getByRole, emitted } = render(SetupPanel, {
props: {
items: [{ id: 'slack', title: 'Slack', completed: true }],
activeItemId: 'slack',
status: 'executing',
},
});
expect(queryByRole('dialog')).toBeNull();
expect(getByRole('status')).toHaveTextContent('Executing');
expect(emitted('update:activeItemId')).toEqual([[undefined]]);
});
});

View File

@ -0,0 +1,25 @@
export interface SetupPanelItem {
/** Stable identity used to keep the detail open as items change. */
id: string;
/** Service name or the label for a group of required fields. */
title: string;
/** Short description of the values that remain to be configured. */
subtitle?: string;
/** Whether all requirements represented by this row are configured. */
completed: boolean;
/** Whether the host has enough context to open this item. */
disabled?: boolean;
/** Whether the action slot replaces opening the detail card for this row. */
hasAction?: boolean;
}
export interface SetupPanelProps {
/** Checklist items in display order. */
items: SetupPanelItem[];
/** The item shown in the overlay, or undefined to show the checklist. */
activeItemId?: string;
/** Current setup or test-execution state. Defaults to the checklist. */
status?: 'incomplete' | 'validating' | 'complete' | 'executing';
/** Whether another operation prevents starting a workflow test. */
executeDisabled?: boolean;
}

View File

@ -0,0 +1,444 @@
<script setup lang="ts">
import { computed, nextTick, onScopeDispose, ref, useId, useTemplateRef, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import type { SetupPanelProps } from './SetupPanel.types';
const props = defineProps<SetupPanelProps>();
const emit = defineEmits<{
'update:activeItemId': [id: string | undefined];
detailClosed: [];
execute: [];
'update:overlapHeight': [height: number];
}>();
const { t } = useI18n();
const activeItem = computed(() =>
props.status === 'executing'
? undefined
: props.items.find((item) => item.id === props.activeItemId && !item.disabled),
);
const panel = useTemplateRef<HTMLElement>('panel');
const overlay = useTemplateRef<HTMLElement>('overlay');
const base = useTemplateRef<HTMLElement>('base');
const { height: baseHeight } = useElementSize(base);
const { height: overlayHeight } = useElementSize(overlay);
watch([baseHeight, overlayHeight, activeItem], () => {
// Keep the clearance while the outgoing overlay is still visible.
if (activeItem.value && overlayHeight.value > 0)
emit('update:overlapHeight', Math.max(0, overlayHeight.value - baseHeight.value));
});
onScopeDispose(() => emit('update:overlapHeight', 0));
function onOverlayLeft() {
if (activeItem.value) return;
emit('update:overlapHeight', 0);
emit('detailClosed');
}
function deactivateOverlay(element: Element) {
element.setAttribute('inert', '');
element.setAttribute('aria-hidden', 'true');
}
function activateOverlay(element: Element) {
element.removeAttribute('inert');
element.removeAttribute('aria-hidden');
}
const titleId = useId();
const expanded = ref(false);
const showTerminal = computed(() => props.status && props.status !== 'incomplete');
const showChecklist = computed(
() => Boolean(activeItem.value) || !showTerminal.value || expanded.value,
);
const checklistId = useId();
watch(
() => props.status,
(status) => {
if (status !== 'complete' && status !== 'executing') return;
if (!activeItem.value || status === 'executing') expanded.value = false;
},
{ immediate: true },
);
watch(
[activeItem, () => props.activeItemId],
([item, id], [previousItem]) => {
if (id && !item) emit('update:activeItemId', undefined);
// An empty panel unmounts the overlay without a leave transition.
if (previousItem && !props.items.length) onOverlayLeft();
},
{ immediate: true },
);
watch(
() => activeItem.value?.id,
async (id, previousId) => {
await nextTick();
if (id)
overlay.value?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
else if (previousId) {
if (!showChecklist.value) {
panel.value
?.querySelector<HTMLButtonElement>('[data-test-id="setup-panel-review"]')
?.focus({ preventScroll: true });
return;
}
const buttons = Array.from(
panel.value?.querySelectorAll<HTMLButtonElement>(
'button[data-setup-item-id]:not(:disabled)',
) ?? [],
);
(
buttons.find((button) => button.dataset.setupItemId === previousId) ??
buttons[0] ??
panel.value?.querySelector<HTMLButtonElement>('button:not(:disabled)')
)?.focus({ preventScroll: true });
}
},
);
</script>
<template>
<section
v-if="items.length"
ref="panel"
:class="$style.panel"
:aria-label="t('setupPanel.label')"
>
<div ref="base" :class="[$style.base, { [$style.hidden]: activeItem }]">
<div
v-if="showTerminal"
:class="$style.terminal"
:inert="Boolean(activeItem && showChecklist)"
:aria-hidden="activeItem && showChecklist ? true : undefined"
data-test-id="setup-panel-terminal"
>
<span v-if="status !== 'complete'" :class="$style.status" role="status">
<N8nIcon icon="loader-circle" size="small" :class="$style.spinner" />
{{ t(status === 'executing' ? 'setupPanel.executing' : 'setupPanel.validating') }}
</span>
<template v-else>
<N8nButton
variant="ghost"
size="small"
:class="$style.review"
data-test-id="setup-panel-review"
:aria-expanded="expanded"
:aria-controls="checklistId"
@click="
expanded = !expanded;
emit('update:activeItemId', undefined);
"
>
{{ t('setupPanel.setupComplete') }}
<N8nIcon :icon="expanded ? 'chevron-down' : 'chevron-right'" size="small" />
</N8nButton>
<N8nButton size="small" :disabled="executeDisabled" @click="emit('execute')">
{{ t('setupPanel.execute') }}
</N8nButton>
</template>
</div>
<Transition
:enter-active-class="$style.enterActive"
:leave-active-class="$style.leaveActive"
:enter-from-class="$style.enterFrom"
:leave-to-class="$style.leaveTo"
>
<div v-show="showChecklist" :class="$style.checklist">
<div :class="{ [$style.listSpacing]: showTerminal }">
<ul
:id="checklistId"
:class="$style.rows"
:inert="Boolean(activeItem) || !showChecklist"
:aria-hidden="activeItem || !showChecklist ? true : undefined"
>
<li v-for="item in items" :key="item.id">
<component
:is="item.hasAction ? 'div' : 'button'"
:role="item.hasAction ? 'group' : undefined"
:aria-label="item.hasAction ? item.title : undefined"
:type="item.hasAction ? undefined : 'button'"
:class="[$style.row, { [$style.actionRow]: item.hasAction }]"
:disabled="item.hasAction ? undefined : item.disabled"
data-test-id="setup-panel-row"
:data-setup-item-id="item.id"
@click="!item.hasAction && emit('update:activeItemId', item.id)"
>
<span :class="$style.icon">
<N8nIcon
v-if="item.completed"
icon="status-completed"
size="large"
color="success"
/>
<slot v-else name="icon" :item="item" />
</span>
<span :class="$style.title">{{ item.title }}</span>
<slot v-if="item.hasAction" name="action" :item="item" />
<span v-else-if="!item.completed && item.subtitle" :class="$style.subtitle">
{{ item.subtitle }}
</span>
<span v-if="item.completed" :class="$style.complete">
{{ t('setupPanel.complete') }}
</span>
<N8nIcon
v-if="!item.hasAction"
icon="chevron-right"
size="small"
:class="$style.chevron"
/>
</component>
</li>
</ul>
</div>
</div>
</Transition>
</div>
<Transition
:enter-active-class="$style.enterActive"
:leave-active-class="$style.leaveActive"
:enter-from-class="$style.enterFrom"
:leave-to-class="$style.leaveTo"
@before-enter="activateOverlay"
@before-leave="deactivateOverlay"
@leave-cancelled="activateOverlay"
@after-leave="onOverlayLeft"
>
<div
v-if="activeItem && showChecklist"
:key="activeItem.id"
ref="overlay"
:class="$style.overlay"
role="dialog"
:aria-labelledby="titleId"
data-test-id="setup-panel-overlay"
@keydown.esc.stop="emit('update:activeItemId', undefined)"
>
<div :class="$style.overlayContent">
<header :class="$style.header">
<N8nButton
variant="ghost"
size="small"
icon-only
:aria-label="t('setupPanel.back')"
data-test-id="setup-panel-back"
@click="emit('update:activeItemId', undefined)"
>
<N8nIcon icon="chevron-left" size="small" />
</N8nButton>
<span :class="$style.icon">
<N8nIcon
v-if="activeItem.completed"
icon="status-completed"
size="large"
color="success"
/>
<slot v-else name="icon" :item="activeItem" />
</span>
<span :id="titleId" :class="$style.title">{{ activeItem.title }}</span>
</header>
<div :class="$style.detail">
<slot name="detail" :item="activeItem" />
</div>
</div>
</div>
</Transition>
</section>
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
.panel {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
font-size: var(--font-size--xs);
line-height: var(--line-height--md);
}
.base,
.overlay {
--animation--popover-in--scale: 0.7;
align-self: end;
}
.base,
.overlay,
.checklist {
transform-origin: bottom center;
}
.base {
@include motion.popover-in;
}
.base,
.enterActive,
.leaveActive {
transition:
transform var(--duration--snappy) var(--easing--ease-out),
opacity var(--duration--snappy) var(--easing--ease-out),
visibility 0s;
@include motion.reduced-motion;
}
.hidden {
visibility: hidden;
transition-delay: 0s, 0s, var(--duration--snappy);
}
.hidden,
.enterFrom,
.leaveTo {
opacity: 0;
transform: scale(0.7);
}
.listSpacing {
padding-top: var(--spacing--2xs);
}
.rows,
.overlay,
.terminal {
border: var(--border);
border-radius: var(--radius--lg);
background: var(--background--surface);
box-shadow: var(--shadow--sm);
}
.terminal {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
min-height: var(--height--2xl);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding-inline: var(--spacing--2xs);
}
.review {
flex: 1;
justify-content: space-between;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
}
.spinner {
@include motion.spin;
}
.overlay {
position: absolute;
inset-inline: 0;
bottom: 0;
z-index: 1;
box-sizing: content-box;
max-height: 60vh;
overflow: hidden;
}
.overlayContent {
display: flex;
flex-direction: column;
max-height: 60vh;
}
.header,
.row {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs);
}
.header {
border-bottom: var(--border);
flex-shrink: 0;
}
.rows {
list-style: none;
margin: 0;
padding: var(--spacing--2xs);
max-height: 50vh;
overflow-y: auto;
}
.row {
width: 100%;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--text-color);
font: inherit;
text-align: left;
cursor: pointer;
min-height: var(--height--xl);
&:hover:not(:disabled) {
background: var(--background--hover);
}
&:disabled {
cursor: default;
color: var(--text-color--disabled);
}
}
.actionRow {
cursor: default;
&:hover:not(:disabled) {
background: none;
}
}
.title {
flex: 1;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--lg);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-color--subtler);
}
.icon {
display: inline-flex;
flex-shrink: 0;
}
.complete {
color: var(--text-color--success);
}
.chevron {
flex-shrink: 0;
color: var(--icon-color);
}
.detail {
min-height: 0;
padding: var(--spacing--sm);
overflow-y: auto;
}
</style>

View File

@ -32,6 +32,10 @@ export { default as N8nAvatar } from './N8nAvatar';
export { default as N8nBadge } from './N8nBadge';
export { default as N8nBlockUi } from './N8nBlockUi';
export { default as N8nButton } from './N8nButton';
export { default as N8nSetupPanel } from './N8nSetupPanel/SetupPanel.vue';
export type { SetupPanelItem, SetupPanelProps } from './N8nSetupPanel/SetupPanel.types';
export { default as N8nSetupConnection } from './N8nSetupConnection/SetupConnection.vue';
export type { SetupConnectionProps } from './N8nSetupConnection/SetupConnection.types';
export { default as N8nButtonList } from './N8nButtonList';
export type { ButtonListOrientation, ButtonListProps } from './N8nButtonList';
export { default as N8nCallout } from './N8nCallout';

View File

@ -2,6 +2,17 @@
import type { N8nLocale } from '../../types';
export default {
'setupPanel.label': 'Workflow setup',
'setupPanel.back': 'Back to setup checklist',
'setupPanel.complete': 'Complete',
'setupPanel.credentialSelected': 'Credential selected',
'setupPanel.setupComplete': 'Setup complete',
'setupPanel.execute': 'Execute',
'setupPanel.validating': 'Validating…',
'setupPanel.executing': 'Executing…',
'setupPanel.connected': 'Connected',
'setupPanel.changeConnection': 'Change connection',
'setupPanel.moreOptions': 'More options',
'generic.retry': 'Retry',
'generic.cancel': 'Cancel',
'generic.close': 'Close',

View File

@ -7102,10 +7102,42 @@
"instanceAi.artifactsPanel.context.agentPreviewNamed": "{name} session",
"instanceAi.artifactsPanel.context.agentPreviewTitled": "{agentName} — {sessionTitle}",
"instanceAi.artifactsPanel.context.agentPreviewSessionTitled": "{sessionTitle}",
"instanceAi.setupPanel.applyError": "Could not apply the changes.",
"instanceAi.setupPanel.applyError": "Changes couldn't be saved. Try again.",
"instanceAi.setupPanel.connected": "Connected",
"instanceAi.setupPanel.executeMessage": "Run a test execution of this workflow.",
"instanceAi.setupPanel.connection": "connection",
"instanceAi.setupPanel.details": "Details",
"instanceAi.setupPanel.needs": "Add {items}",
"instanceAi.setupPanel.connect": "Connect",
"instanceAi.setupPanel.advancedSetup": "Advanced setup",
"instanceAi.setupPanel.switchAccount": "Switch account",
"instanceAi.setupPanel.connectWithOAuth": "Connect with OAuth instead",
"instanceAi.setupPanel.replaceKey": "Create new credential",
"instanceAi.setupPanel.editCredential": "Edit credential",
"instanceAi.setupPanel.useExisting": "Use an existing credential",
"instanceAi.setupPanel.account": "Account",
"instanceAi.setupPanel.apiKey": "API key",
"instanceAi.setupPanel.credential": "Credential",
"instanceAi.setupPanel.accessDescription": "This workflow needs a credential to access {service}.",
"instanceAi.setupPanel.helpFindApiKey": "Help me find my API key",
"instanceAi.setupPanel.helpFindAccessToken": "Help me find my access token",
"instanceAi.setupPanel.helpSetUp": "Help me set this up",
"instanceAi.setupPanel.helpVisibleFields": "The OAuth form asks for: {fields}.",
"instanceAi.setupPanel.helpManagedOAuthAvailable": "Managed OAuth is available on this instance.",
"instanceAi.setupPanel.helpManagedOAuthUnavailable": "Managed OAuth isn't available on this instance. Help me configure my own OAuth app.",
"instanceAi.setupPanel.setCredentialsPerNode": "Set credentials per node",
"instanceAi.setupPanel.creditsDescription": "Use Gateway credits without adding an API key.",
"instanceAi.setupPanel.connectedWith": "Connected with",
"instanceAi.setupPanel.useOwnKey": "Use my API key",
"instanceAi.setupPanel.useCredits": "Use credits",
"instanceAi.setupPanel.manageCredits": "Manage Gateway credits",
"instanceAi.setupPanel.saveAndSignIn": "Save and sign in",
"instanceAi.setupPanel.redirectUrl": "Add this redirect URL to your app",
"instanceAi.setupPanel.copyRedirectUrl": "Copy redirect URL",
"instanceAi.setupPanel.connectionError": "Connection failed. Try again.",
"instanceAi.setupPanel.executedMessage": "The workflow execution finished with execution id \"{executionId}\"",
"instanceAi.setupPanel.executeError": "The workflow test couldn't start. Try again.",
"instanceAi.setupPanel.ready": "Ready",
"node.setupRequired": "Add a credential in the setup panel",
"instanceAi.projectSelect.tooltip": "Where n8n Assistant creates automations",
"instanceAi.projectSelect.placeholder": "Select a project",
"instanceAi.projectSelect.search": "Search projects",

View File

@ -31,6 +31,8 @@ export interface InstanceAiCredentialContext {
documentationUrl?: string;
/** OAuth redirect/callback URL shown in the modal, when this is an OAuth credential. */
oauthRedirectUrl?: string;
/** Instance-specific setup guidance appended to the help question. */
setupContext?: string;
}
/**

View File

@ -57,7 +57,6 @@ import { useExecutionsStore } from '@/features/execution/executions/executions.s
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
import { useCanvasOperations } from './useCanvasOperations';
import { chatEventBus } from '@n8n/chat/event-buses';
@ -65,6 +64,7 @@ import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { useWorkflowSaving } from './useWorkflowSaving';
import { useDocumentTitle } from './useDocumentTitle';
import { useEditorContext } from './useEditorContext';
import { useRunWorkflowApi } from './useRunWorkflowApi';
import { useChat } from '@n8n/chat/composables';
import type { WorkflowObjectAccessors } from '../types';
@ -89,8 +89,8 @@ export function useRunWorkflow(useRunWorkflowOpts: {
const agentRequestStore = useAgentRequestStore();
const rootStore = useRootStore();
const pushConnectionStore = usePushConnectionStore();
const workflowsStore = useWorkflowsStore();
const workflowRunner = useRunWorkflowApi();
const workflowDocumentStore =
useRunWorkflowOpts.workflowDocumentStore ?? injectWorkflowDocumentStore();
const workflowExecutionState = computed(() =>
@ -127,35 +127,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
// Starts to execute a workflow on server
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
if (!pushConnectionStore.isConnected) {
// Do not start if the connection to server is not active
// because then it can not receive the data as it executes.
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
}
// Set the execution as started, but still waiting for the execution to be retrieved
workflowExecutionState.value.setActiveExecutionId(null);
let response: IExecutionPushResponse;
try {
response = await workflowsStore.runWorkflow(runData);
} catch (error) {
workflowExecutionState.value.setActiveExecutionId(undefined);
throw error;
}
const workflowExecutionIdIsNew =
workflowExecutionState.value.previousExecutionId !== response.executionId;
const workflowExecutionIdIsPending = workflowExecutionState.value.activeExecutionId === null;
if (response.executionId && workflowExecutionIdIsNew && workflowExecutionIdIsPending) {
workflowExecutionState.value.setActiveExecutionId(response.executionId);
}
if (response.waitingForWebhook === true) {
workflowExecutionState.value.setExecutionWaitingForWebhook(true);
}
return response;
return await workflowRunner.runWorkflowApi(runData, workflowDocumentStore.value.documentId);
}
async function runWorkflow(options: {

View File

@ -0,0 +1,46 @@
import { useI18n } from '@n8n/i18n';
import type { IStartRunData } from '@/Interface';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { IExecutionPushResponse } from '@/features/execution/executions/executions.types';
export function useRunWorkflowApi() {
const i18n = useI18n();
const pushConnectionStore = usePushConnectionStore();
const workflowsStore = useWorkflowsStore();
async function runWorkflowApi(
runData: IStartRunData,
documentId: WorkflowDocumentId,
): Promise<IExecutionPushResponse> {
if (!pushConnectionStore.isConnected) {
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
}
const executionState = useWorkflowExecutionStateStore(documentId);
// Push handlers only accept this run after the document starts tracking it.
executionState.setActiveExecutionId(null);
let response: IExecutionPushResponse;
try {
response = await workflowsStore.runWorkflow(runData);
} catch (error) {
executionState.setActiveExecutionId(undefined);
throw error;
}
if (
response.executionId &&
executionState.previousExecutionId !== response.executionId &&
executionState.activeExecutionId === null
) {
executionState.setActiveExecutionId(response.executionId);
}
if (response.waitingForWebhook === true) executionState.setExecutionWaitingForWebhook(true);
return response;
}
return { runWorkflowApi };
}

View File

@ -49,6 +49,9 @@ export const AGENT_EXTERNAL_UPDATE_NOTICE_DURATION = 5 * TIME.MINUTE;
/** Keeps short agent tool and skill calls perceptible without adding continuous motion. */
export const AGENT_CAPABILITY_ACTIVE_MIN_DURATION = 300;
/** Keeps a completed setup item visible briefly before returning to the checklist. */
export const SETUP_PANEL_SUCCESS_DELAY = 650;
/** Hover-intent delays for reveal-on-hover affordances (e.g. a collapsed group's description). */
export const HOVER_DELAY = {
/** Delay before a hovered affordance reveals its content. */

View File

@ -31,6 +31,8 @@ export const ExecutionDataStoreKey: InjectionKey<
// derived from it via injectWorkflowExecutionStateStore(), so a subtree's
// document scope and execution scope can never diverge.
export const CanvasRenderDataKey: InjectionKey<Ref<CanvasRenderData>> = Symbol('CanvasRenderData');
/** Keep setup hints compact while retaining their full text on the field. */
export const CompactParameterHintsKey: InjectionKey<boolean> = Symbol('CompactParameterHints');
/**
* Opts resource-locator dropdowns into teleporting to `<body>`. Defaults to
* `false` (stay in the local stacking context, e.g. inside the NDV dialog).
@ -92,6 +94,8 @@ export type EditorEnabledFeatures = Partial<Record<EditorFeature, boolean>> & {
executionSuccessToasts?: boolean;
executionErrorToasts?: boolean;
executionButtonType?: 'primary' | 'secondary';
/** Show missing credentials as setup warnings in hosts with a setup panel. */
credentialSetupWarnings?: boolean;
};
export const EditorEnabledFeaturesKey: InjectionKey<Readonly<Ref<EditorEnabledFeatures>>> =
Symbol('EditorEnabledFeatures');

View File

@ -27,6 +27,16 @@ export const LOCAL_STORAGE_CHAT_HUB_HAD_CONVERSATION_BEFORE = (userId: string) =
`${userId}_N8N_CHAT_HUB_HAD_CONVERSATION_BEFORE`;
export const LOCAL_STORAGE_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN = (threadId: string) =>
`N8N_INSTANCE_AI_ARTIFACT_PREVIEW_OPEN:${threadId}`;
export const LOCAL_STORAGE_INSTANCE_AI_SETUP_ITEMS = (
userId: string,
threadId: string,
workflowId: string,
) => `N8N_INSTANCE_AI_SETUP_ITEMS:${userId}:${threadId}:${workflowId}`;
export const LOCAL_STORAGE_INSTANCE_AI_SETUP_DISMISSED = (
userId: string,
threadId: string,
workflowId: string,
) => `N8N_INSTANCE_AI_SETUP_DISMISSED:${userId}:${threadId}:${workflowId}`;
export const LOCAL_STORAGE_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO =
'N8N_INSTANCE_AI_CHAT_PANEL_WIDTH_RATIO';
export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH';

View File

@ -91,6 +91,15 @@ export const MARKDOWN_NODE_TYPE = 'n8n-nodes-base.markdown';
export const XML_NODE_TYPE = 'n8n-nodes-base.xml';
export const CRYPTO_NODE_TYPE = 'n8n-nodes-base.crypto';
export const RSS_READ_NODE_TYPE = 'n8n-nodes-base.rssFeedRead';
// These nodes can use arbitrary providers. Gateway credentials require a known provider.
export const AI_GATEWAY_UNSUPPORTED_NODE_TYPES: readonly string[] = [
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.httpRequestTool',
'@n8n/n8n-nodes-langchain.toolHttpRequest',
'n8n-nodes-base.graphql',
'n8n-nodes-base.graphqlTool',
];
export const COMPRESSION_NODE_TYPE = 'n8n-nodes-base.compression';
export const EDIT_IMAGE_NODE_TYPE = 'n8n-nodes-base.editImage';
export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE =

View File

@ -8,7 +8,7 @@ import {
function renderMarkdown(content: string): string {
const markdown = useChatHubMarkdownOptions('code-actions', 'table-container', null);
const renderer = new MarkdownIt();
const renderer = new MarkdownIt(markdown.options);
for (const plugin of markdown.plugins.value) {
renderer.use(plugin);
@ -18,6 +18,12 @@ function renderMarkdown(content: string): string {
}
describe('useChatHubMarkdownOptions', () => {
it('makes plain URLs clickable while preserving code examples', () => {
const html = renderMarkdown('Open https://api.slack.com/apps or copy `https://example.test`.');
expect(html).toContain('href="https://api.slack.com/apps" target="_blank" rel="noopener"');
expect(html).toContain('<code>https://example.test</code>');
});
it('renders app-relative links without a new-tab target', () => {
const html = renderMarkdown('[Preview](/projects/project-1/agents/agent-1/preview)');

View File

@ -82,6 +82,7 @@ export function useChatHubMarkdownOptions(
const options = {
breaks: true,
linkify: true,
highlight(str: string, lang: string) {
if (!lang) {
return ''; // use external default escaping

View File

@ -220,8 +220,7 @@ watch(
{ immediate: true },
);
// --- Setup panel (checklist docked above the composer) ---
// Anchors to the active canvas tab's workflow; on a hydrated thread with no
// tab state yet, the latest workflow artifact wins (insertion order).
// Early setup announcements can arrive before the first workflow artifact.
const setupPanelWorkflowId = computed(() => {
if (!settingsStore.isInstanceAiSetupPanelEnabled) return undefined;
const active = preview.activeWorkflowId.value;
@ -230,13 +229,14 @@ const setupPanelWorkflowId = computed(() => {
for (const entry of thread.producedArtifacts.values()) {
if (entry.type === 'workflow') latest = entry.id;
}
return latest;
return latest ?? thread.latestSetupWorkflowId;
});
const setupPanelProjectId = computed(() =>
setupPanelWorkflowId.value
? thread.producedArtifacts.get(setupPanelWorkflowId.value)?.projectId
: undefined,
);
const setupOverlapHeight = ref(0);
const agentReturnContext = useAgentReturnContextStore().consumePendingArtifactReturn();
const agentReturnWorkflowId = agentReturnContext?.workflowId;
@ -809,6 +809,7 @@ async function persistTestAgentOfferDismissal(agentId: string) {
>
<InstanceAiConversation
ref="conversation"
:above-input-overlap-height="setupPanelWorkflowId ? setupOverlapHeight : undefined"
@thread-missing="onThreadMissing"
@agent-attachment-restored="onAgentAttachmentRestored"
>
@ -817,6 +818,7 @@ async function persistTestAgentOfferDismissal(agentId: string) {
v-if="setupPanelWorkflowId"
:workflow-id="setupPanelWorkflowId"
:project-id="setupPanelProjectId"
@update:overlap-height="setupOverlapHeight = $event"
/>
</template>
<template #inline-offers>

View File

@ -529,6 +529,42 @@ describe('InstanceAiThreadView', () => {
});
});
it('shows an announced workflow before its first artifact exists', async () => {
seedSetupArtifacts();
thread.producedArtifacts = new Map();
const rendered = renderView({ props: { threadId: 'thread-1' } });
expect(rendered.queryByTestId('setup-panel')).not.toBeInTheDocument();
thread.setupItemsByWorkflowId = {
'wf-early': [
{ id: 'wf-early:credential:slackApi', kind: 'credential', credentialType: 'slackApi' },
],
};
thread.latestSetupWorkflowId = 'wf-early';
await flushPromises();
expect(rendered.getByTestId('setup-panel')).toHaveAttribute('data-workflow-id', 'wf-early');
expect(rendered.getByTestId('setup-panel')).not.toHaveAttribute('data-project-id');
});
it('does not pick an arbitrary workflow from legacy setup rows', () => {
seedSetupArtifacts();
thread.producedArtifacts = new Map();
thread.latestSetupWorkflowId = undefined;
thread.setupItemsByWorkflowId = Object.fromEntries(
['wf-1', 'wf-2'].map((id) => [
id,
[
{
id: `${id}:credential:slackApi`,
kind: 'credential' as const,
credentialType: 'slackApi',
},
],
]),
);
const { queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
expect(queryByTestId('setup-panel')).not.toBeInTheDocument();
});
it('follows the selected workflow and project when tabs change', async () => {
seedSetupArtifacts();
thread.messages[0].attachments = [{ type: 'workflow', id: 'wf-1', name: 'First workflow' }];

View File

@ -1166,6 +1166,32 @@ describe('getLatestWorkflowUpdateResult', () => {
});
describe('isAgentEditingWorkflow', () => {
test('locks an announced workflow while its first build has no result yet', () => {
const call = makeToolCall({
toolName: 'build-workflow',
args: { filePath: 'workflow.ts' },
isLoading: true,
startedAt: '2026-09-15T08:00:00.000Z',
});
const node = makeAgentNode({
status: 'active',
toolCalls: [call],
setupItemsByWorkflowId: { 'wf-other': [], 'wf-1': [] },
latestSetupAnnouncement: {
workflowId: 'wf-1',
agentId: 'agent-1',
timestamp: '2026-09-15T08:00:01.000Z',
},
});
expect(isAgentEditingWorkflow(node, 'wf-1')).toBe(true);
expect(isAgentEditingWorkflow(node, 'wf-other')).toBe(false);
call.startedAt = '2026-09-15T08:01:00.000Z';
expect(isAgentEditingWorkflow(node, 'wf-1')).toBe(false);
call.startedAt = '2026-09-15T08:00:00.000Z';
call.isLoading = false;
expect(isAgentEditingWorkflow(node, 'wf-1')).toBe(false);
});
test('locks while an active agent run has already built the workflow', () => {
const node = makeAgentNode({
status: 'active',

View File

@ -28,6 +28,8 @@ export function makeThread(): ThreadRuntime {
contextualSuggestion: null,
currentTasks: null,
producedArtifacts: new Map(),
setupItemsByWorkflowId: {},
projectId: 'thread-project',
resourceNameIndex: new Map(),
linkableResourceNameIndex: new Map(),
activeArtifactId: undefined,

View File

@ -390,6 +390,14 @@ describe('createThreadRuntime - SSE and hydration', () => {
credentialType: 'slackApi',
},
],
'wf-2': [
{ id: 'wf-2:credential:slackApi', kind: 'credential', credentialType: 'slackApi' },
],
},
latestSetupAnnouncement: {
workflowId: 'wf-1',
agentId: 'agent-root',
timestamp: '2026-09-15T08:00:00.000Z',
},
},
},
@ -402,8 +410,62 @@ describe('createThreadRuntime - SSE and hydration', () => {
expect(runtime.setupItemsByWorkflowId['wf-1']).toHaveLength(1);
expect(runtime.setupItemsByWorkflowId['wf-1'][0]).toMatchObject({ credentialType: 'slackApi' });
expect(runtime.latestSetupWorkflowId).toBe('wf-1');
});
test.each([
{ workflowIds: ['wf-1', 'wf-2'], completed: false, expected: undefined },
{ workflowIds: ['wf-1', 'wf-2'], completed: true, expected: undefined },
{ workflowIds: ['wf-1'], completed: false, expected: 'wf-1' },
{ workflowIds: ['wf-1'], completed: true, expected: 'wf-1' },
])(
'restores legacy setup selection without inferring key order: %j',
async ({ workflowIds, completed, expected }) => {
mockFetchThreadMessages.mockResolvedValueOnce({
threadId: 'thread-legacy',
messages: [
{
id: 'msg-legacy',
role: 'assistant',
createdAt: '2026-09-15T08:00:00.000Z',
content: '',
reasoning: '',
isStreaming: false,
agentTree: {
agentId: 'root',
role: 'orchestrator',
status: 'completed',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
setupItemsByWorkflowId: Object.fromEntries(
workflowIds.map((id) => [
id,
completed && id === 'wf-1'
? []
: [
{
id: `${id}:credential:slackApi`,
kind: 'credential' as const,
credentialType: 'slackApi',
},
],
]),
),
},
},
],
nextEventId: 10,
});
const runtime = registry.getOrCreateRuntime('thread-legacy');
await runtime.loadHistoricalMessages();
expect(runtime.latestSetupWorkflowId).toBe(expected);
expect(Object.keys(runtime.setupItemsByWorkflowId)).toEqual(workflowIds);
},
);
test('background-group run-sync does not overwrite activeRunId from orchestrator sync', () => {
// First, create two assistant messages via normal events
capturedOnMessage!(

View File

@ -0,0 +1,65 @@
import { mock } from 'vitest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { createTestNode } from '@/__tests__/mocks';
import { groupSetupPanelRows } from '../setupPanelGroups';
import type { SetupPanelRow } from '../composables/useSetupPanelState';
const parameters = (nodeName: string): SetupPanelRow => ({
item: {
id: `wf:parameters:${nodeName}`,
kind: 'parameters',
nodeName,
parameterNames: ['value'],
},
isDone: false,
});
const credential: SetupPanelRow = {
item: {
id: 'wf:credential:slackApi',
kind: 'credential',
credentialType: 'slackApi',
nodeBindings: [{ nodeName: 'Send' }, { nodeName: 'Read' }],
},
isDone: false,
};
const nodes = [
createTestNode({ name: 'Feed', type: 'n8n-nodes-base.rssFeedRead' }),
createTestNode({ name: 'Schedule', type: 'n8n-nodes-base.scheduleTrigger' }),
createTestNode({ name: 'Service', type: 'service' }),
];
const options = {
workflowId: 'wf',
getNodeByName: (name: string) => nodes.find((node) => node.name === name),
getNodeType: (node: (typeof nodes)[number]) =>
mock<INodeTypeDescription>({
codex: { categories: [node.type === 'service' ? 'Marketing' : 'Core Nodes'] },
}),
};
describe('groupSetupPanelRows', () => {
it('combines all bound service fields and leaves no empty Details group', () => {
const send = parameters('Send');
const read = parameters('Read');
expect(groupSetupPanelRows([send, credential, read], options)).toEqual([
{ id: credential.item.id, credential, parameters: [send, read] },
]);
});
it('keeps RSS and standalone services named and collects unrelated fields in one Details group', () => {
const groups = groupSetupPanelRows(
['Feed', 'Schedule', 'Service', 'Unknown'].map(parameters),
options,
);
expect(
groups.map((group) => ({
id: group.id,
names: group.parameters.map((row) => row.item.nodeName),
node: group.node?.name,
})),
).toEqual([
{ id: 'wf:parameters:Feed', names: ['Feed'], node: 'Feed' },
{ id: 'wf:parameters:Service', names: ['Service'], node: 'Service' },
{ id: 'wf:details', names: ['Schedule', 'Unknown'], node: undefined },
]);
});
});

View File

@ -3,13 +3,12 @@ import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ResponseError } from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { INodeTypeDescription } from 'n8n-workflow';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import type { IWorkflowDb } from '@/Interface';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { getWorkflow } from '@/app/api/workflows';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
@ -21,6 +20,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useSetupPanelActions,
type SetupCredentialItem,
type SetupCredentialRef,
type SetupPanelApplyResult,
} from '../composables/useSetupPanelActions';
@ -67,12 +67,11 @@ function createHarness(
options: {
agentBuilding?: boolean;
workflowId?: string | undefined;
onFlushResult?: (result: SetupPanelApplyResult) => void;
onFlushResult?: (result: SetupPanelApplyResult, workflowId: string) => void;
} = {},
) {
const building = ref(options.agentBuilding ?? false);
const workflowId = ref('workflowId' in options ? options.workflowId : WORKFLOW_ID);
const sendMessage = vi.fn().mockResolvedValue(true);
const updateWorkflow = vi
.fn()
@ -83,15 +82,60 @@ function createHarness(
vi.mocked(getWorkflow).mockImplementation(async () => makeWorkflow());
const actions = useSetupPanelActions({
thread: { sendMessage },
workflowId: () => workflowId.value,
isAgentBuilding: () => building.value,
onFlushResult: options.onFlushResult,
});
return { actions, building, workflowId, sendMessage, updateWorkflow };
return { actions, building, workflowId, updateWorkflow };
}
describe('useSetupPanelActions', () => {
it('keeps a per-node selection visible while a shared and per-node bind drain together', async () => {
const { actions, building, updateWorkflow } = createHarness({ agentBuilding: true });
const shared = {
...credentialItem,
nodeBindings: [{ nodeName: 'Slack' }, { nodeName: 'Second' }],
};
const second = {
...credentialItem,
id: `${credentialItem.id}:Second`,
nodeBindings: [{ nodeName: 'Second' }],
};
const secondCredential = { id: 'cred-2', name: 'Second account' };
await actions.bindCredential(shared, credential);
await actions.bindCredential(second, secondCredential);
expect(actions.getPendingCredential(shared.id, 'Second')).toEqual(secondCredential);
vi.mocked(getWorkflow).mockImplementation(async () => {
expect(actions.getPendingCredential(shared.id, 'Slack')).toEqual(credential);
expect(actions.getPendingCredential(shared.id, 'Second')).toEqual(secondCredential);
return makeWorkflow({
nodes: [createTestNode({ name: 'Slack' }), createTestNode({ name: 'Second' })],
});
});
building.value = false;
await expect(actions.flushPendingApplies()).resolves.toBe('applied');
expect(updateWorkflow).toHaveBeenCalledTimes(1);
expect(
updateWorkflow.mock.calls[0][1].nodes.map((node: INodeUi) => node.credentials?.slackApi),
).toEqual([credential, secondCredential]);
});
it.each([false, true])('tracks a draining write until it settles, failed: %s', async (failed) => {
const { actions, building } = createHarness({ agentBuilding: true });
await actions.bindCredential(credentialItem, credential);
vi.mocked(getWorkflow).mockImplementation(async () => {
expect(actions.pendingApplyCount.value).toBe(0);
expect(actions.isApplying.value).toBe(true);
expect(actions.getPendingCredential(credentialItem.id)).toEqual(credential);
if (failed) throw new Error('Unavailable');
return makeWorkflow();
});
building.value = false;
await expect(actions.flushPendingApplies()).resolves.toBe(failed ? 'error' : 'applied');
expect(actions.isApplying.value).toBe(false);
expect(actions.getPendingCredential(credentialItem.id)).toBeUndefined();
});
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
vi.mocked(getWorkflow).mockReset();
@ -101,6 +145,77 @@ describe('useSetupPanelActions', () => {
getNodeInputIssues.mockReturnValue(null);
});
it.each<SetupCredentialRef>([
{ id: null, name: '', __aiGatewayManaged: true },
{ id: 'own-key', name: 'Own key' },
])(
'persists a deliberate switch between Gateway and own credentials: $id',
async (nextCredential) => {
const { actions, updateWorkflow } = createHarness();
vi.mocked(getWorkflow).mockResolvedValue(
makeWorkflow({
nodes: [
createTestNode({
name: 'Slack',
credentials: {
slackApi: nextCredential.id
? { id: null, name: '', __aiGatewayManaged: true }
: credential,
},
}),
],
}),
);
expect(await actions.bindCredential(credentialItem, nextCredential)).toBe('applied');
expect(updateWorkflow.mock.calls[0][1].nodes[0].credentials.slackApi).toEqual(nextCredential);
},
);
it('does not rewrite an existing managed binding', async () => {
const { actions, updateWorkflow } = createHarness();
const managed = { id: null, name: '', __aiGatewayManaged: true };
vi.mocked(getWorkflow).mockResolvedValue(
makeWorkflow({
nodes: [createTestNode({ name: 'Slack', credentials: { slackApi: managed } })],
}),
);
expect(await actions.bindCredential(credentialItem, managed)).toBe('noop');
expect(updateWorkflow).not.toHaveBeenCalled();
});
it('resolves an early announcement again after a version conflict changes its nodes', async () => {
const { actions, updateWorkflow, building } = createHarness({ agentBuilding: true });
const nodeTypes = mockedStore(useNodeTypesStore);
nodeTypes.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
nodeTypes.getNodeType = vi
.fn()
.mockReturnValue({ credentials: [{ name: 'slackApi', required: true }], properties: [] });
vi.mocked(getWorkflow)
.mockResolvedValueOnce(makeWorkflow({ nodes: [createTestNode({ name: 'First' })] }))
.mockResolvedValueOnce(
makeWorkflow({
nodes: [
createTestNode({ name: 'Second' }),
createTestNode({ name: 'Third' }),
createTestNode({ name: 'Disabled', disabled: true }),
],
}),
);
updateWorkflow.mockRejectedValueOnce(conflictError());
expect(
await actions.bindCredential({ ...credentialItem, nodeBindings: undefined }, credential),
).toBe('queued');
expect(getWorkflow).not.toHaveBeenCalled();
building.value = false;
await vi.waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2));
expect(updateWorkflow.mock.calls[1][1].nodes).toEqual([
expect.objectContaining({ name: 'Second', credentials: { slackApi: credential } }),
expect.objectContaining({ name: 'Third', credentials: { slackApi: credential } }),
expect.objectContaining({ name: 'Disabled' }),
]);
expect(updateWorkflow.mock.calls[1][1].nodes[2].credentials).toBeUndefined();
});
it('binds a credential through the version-guarded workflow PATCH', async () => {
const { actions, updateWorkflow } = createHarness();
@ -235,6 +350,7 @@ describe('useSetupPanelActions', () => {
await actions.bindCredential(credentialItem, { id: 'cred-old', name: 'Old' });
await actions.bindCredential(credentialItem, credential);
expect(actions.pendingApplyCount.value).toBe(1);
expect(actions.getPendingCredential(credentialItem.id)).toEqual(credential);
await actions.flushPendingApplies();
expect(updateWorkflow).not.toHaveBeenCalled();
@ -263,6 +379,10 @@ describe('useSetupPanelActions', () => {
).resolves.toBe('queued');
await expect(actions.applyParameterValues('Slack', { channel: '#b' })).resolves.toBe('queued');
expect(actions.pendingApplyCount.value).toBe(1);
expect(actions.getPendingParameterChanges('Slack')).toEqual([
{ path: ['text'], value: 'hi' },
{ path: ['channel'], value: '#b' },
]);
building.value = false;
await vi.waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(1));
@ -311,6 +431,7 @@ describe('useSetupPanelActions', () => {
workflowId.value = 'wf-2';
building.value = false;
expect(actions.getPendingCredential(credentialItem.id)).toBeUndefined();
await vi.waitFor(() => expect(actions.pendingApplyCount.value).toBe(0));
expect(getWorkflow).not.toHaveBeenCalled();
@ -341,7 +462,9 @@ describe('useSetupPanelActions', () => {
updateWorkflow.mockRejectedValue(conflictError());
building.value = false;
await vi.waitFor(() => expect(onFlushResult).toHaveBeenCalledExactlyOnceWith('conflict'));
await vi.waitFor(() =>
expect(onFlushResult).toHaveBeenCalledExactlyOnceWith('conflict', WORKFLOW_ID),
);
});
it('does not report a settle flush when nothing is queued', async () => {
@ -527,23 +650,4 @@ describe('useSetupPanelActions', () => {
input: { main: ['Input "main" is missing'] },
});
});
it('sends the Execute message through the normal send endpoint with the setup panel context', async () => {
const { actions, sendMessage } = createHarness();
await expect(actions.executeWorkflow()).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledExactlyOnceWith('Run a test execution of this workflow.', {
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: useRootStore().pushRef,
handoffContext: { source: 'setup-panel-execute', workflowId: WORKFLOW_ID },
});
});
it('does not send Execute without an active artifact workflow', async () => {
const { actions, sendMessage } = createHarness({ workflowId: undefined });
await expect(actions.executeWorkflow()).resolves.toBe(false);
expect(sendMessage).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,422 @@
import { effectScope, ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { flushPromises } from '@vue/test-utils';
import { mock } from 'vitest-mock-extended';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type { PushMessage, InstanceAiMessage } from '@n8n/api-types';
import type { TerminalExecutionStatus } from 'n8n-workflow';
import { useRootStore } from '@n8n/stores/useRootStore';
import { mockedStore } from '@/__tests__/utils';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { getWorkflow } from '@/app/api/workflows';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { executionStarted } from '@/app/composables/usePushConnection/handlers/executionStarted';
import { nodeExecuteBefore } from '@/app/composables/usePushConnection/handlers/nodeExecuteBefore';
import type { PushHandlerOptions } from '@/app/composables/usePushConnection/handlers/types';
import type {
IExecutionPushResponse,
IExecutionResponse,
} from '@/features/execution/executions/executions.types';
import { useSetupPanelExecution } from '../composables/useSetupPanelExecution';
vi.mock('@/app/api/workflows', () => ({ getWorkflow: vi.fn() }));
const { track } = vi.hoisted(() => ({ track: vi.fn() }));
vi.mock('@n8n/composables/useTelemetry', () => ({ useTelemetry: () => ({ track }) }));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage: vi.fn() }) }));
const workflow = createTestWorkflow({
id: 'wf-1',
nodes: [createTestNode({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' })],
});
const handlers = new Set<(event: PushMessage) => void>();
const scopes: Array<ReturnType<typeof effectScope>> = [];
const pushOptions = mock<PushHandlerOptions>({ documentId: createWorkflowDocumentId('wf-1') });
function finish(
status: TerminalExecutionStatus = 'success',
executionId = 'exec-1',
workflowId = 'wf-1',
) {
for (const handler of handlers)
handler({ type: 'executionFinished', data: { executionId, workflowId, status } });
}
function harness() {
const scope = effectScope();
scopes.push(scope);
const workflowId = ref<string | undefined>('wf-1');
const thread = {
id: 'thread-1',
messages: [] as InstanceAiMessage[],
sendMessage: vi.fn().mockResolvedValue(true),
rememberManualExecution: vi.fn(),
};
const execution = scope.run(() => useSetupPanelExecution({ workflowId, thread }))!;
return {
...execution,
scope,
thread,
workflowId,
workflows: mockedStore(useWorkflowsStore),
push: mockedStore(usePushConnectionStore),
};
}
describe('useSetupPanelExecution', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
handlers.clear();
track.mockClear();
vi.mocked(getWorkflow).mockReset().mockResolvedValue(workflow);
const nodeTypes = mockedStore(useNodeTypesStore);
nodeTypes.loadNodeTypesIfNotLoaded.mockResolvedValue(undefined);
nodeTypes.isTriggerNode = vi.fn((type: string) => type === 'n8n-nodes-base.manualTrigger');
const push = mockedStore(usePushConnectionStore);
push.isConnected = true;
push.addEventListener.mockImplementation((handler) => {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
});
const workflows = mockedStore(useWorkflowsStore);
workflows.runWorkflow.mockResolvedValue({ executionId: 'exec-1' });
workflows.fetchExecutionDataById.mockResolvedValue(null);
});
afterEach(() => {
for (const scope of scopes.splice(0)) scope.stop();
});
it.each(['success', 'error', 'canceled'] as const)(
'notifies the agent only after the actual execution finishes: %s',
async (status) => {
const { executeWorkflow, workflows, thread } = harness();
const pending = executeWorkflow();
await flushPromises();
expect(workflows.runWorkflow).toHaveBeenCalledWith({
workflowId: 'wf-1',
triggerToStartFrom: { name: 'Start' },
});
expect(thread.sendMessage).not.toHaveBeenCalled();
finish(status);
await expect(pending).resolves.toEqual({
workflowId: 'wf-1',
executionId: 'exec-1',
status,
notified: true,
});
expect(thread.sendMessage).toHaveBeenCalledExactlyOnceWith(
'The workflow execution finished with execution id "exec-1"',
{
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: useRootStore().pushRef,
},
);
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.WORKFLOW.USER_REQUESTED_WORKFLOW_TEST, {
source: 'instance_ai_setup_panel',
workflow_id: 'wf-1',
thread_id: 'thread-1',
});
expect(handlers.size).toBe(0);
},
);
it('matches completion by both workflow and execution ID', async () => {
const { executeWorkflow, thread } = harness();
const pending = executeWorkflow();
await flushPromises();
finish('success', 'other-execution');
finish('success', 'exec-1', 'other-workflow');
await flushPromises();
expect(thread.sendMessage).not.toHaveBeenCalled();
finish();
await pending;
expect(thread.sendMessage).toHaveBeenCalledOnce();
});
it.each([false, true])(
'tracks live canvas node events when the start response arrives first: %s',
async (responseFirst) => {
const { executeWorkflow, workflows } = harness();
const response = Promise.withResolvers<IExecutionPushResponse>();
workflows.runWorkflow.mockReturnValueOnce(response.promise);
useWorkflowDocumentStore(pushOptions.documentId).hydrate(workflow);
const state = useWorkflowExecutionStateStore(pushOptions.documentId);
const pending = executeWorkflow();
await flushPromises();
expect(state.activeExecutionId).toBeNull();
if (responseFirst) {
response.resolve({ executionId: 'exec-1' });
await flushPromises();
}
await executionStarted(
{
type: 'executionStarted',
data: {
executionId: 'exec-1',
workflowId: 'wf-1',
mode: 'manual',
startedAt: new Date(),
flattedRunData: '',
},
},
pushOptions,
);
await nodeExecuteBefore(
{
type: 'nodeExecuteBefore',
data: {
executionId: 'exec-1',
nodeName: 'Start',
sequenceNumber: 0,
data: { startTime: Date.now(), executionIndex: 0, source: [] },
},
},
pushOptions,
);
expect(state.activeExecutionId).toBe('exec-1');
expect(state.displayedExecutionId).toBe('exec-1');
expect(state.executingNode.isNodeExecuting('Start')).toBe(true);
response.resolve({ executionId: 'exec-1' });
await flushPromises();
finish();
await pending;
},
);
it('handles a completion event arriving before the start response', async () => {
const { executeWorkflow, workflows, thread } = harness();
workflows.runWorkflow.mockImplementationOnce(async () => {
finish();
return { executionId: 'exec-1' };
});
await expect(executeWorkflow()).resolves.toMatchObject({ status: 'success', notified: true });
expect(thread.sendMessage).toHaveBeenCalledOnce();
});
it('reads the status after reconnecting if the completion event was missed', async () => {
const { executeWorkflow, workflows, push, thread } = harness();
const pending = executeWorkflow();
await flushPromises();
push.isConnected = false;
await flushPromises();
workflows.fetchExecutionDataById.mockResolvedValue({
id: 'exec-1',
workflowId: 'wf-1',
status: 'success',
} as IExecutionResponse);
push.isConnected = true;
await pending;
expect(thread.sendMessage).toHaveBeenCalledOnce();
});
it('waits for a webhook execution to start and finish', async () => {
const { executeWorkflow, workflows, thread } = harness();
workflows.runWorkflow.mockResolvedValueOnce({ waitingForWebhook: true });
const pending = executeWorkflow();
await flushPromises();
const state = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
expect(state.activeExecutionId).toBeNull();
expect(thread.sendMessage).not.toHaveBeenCalled();
for (const handler of handlers)
handler({
type: 'executionStarted',
data: {
executionId: 'exec-1',
workflowId: 'wf-1',
mode: 'manual',
startedAt: new Date(),
flattedRunData: '',
},
});
expect(thread.sendMessage).not.toHaveBeenCalled();
expect(state.activeExecutionId).toBe('exec-1');
finish();
await pending;
expect(state.activeExecutionId).toBeUndefined();
expect(state.isWorkflowRunning).toBe(false);
expect(thread.rememberManualExecution).toHaveBeenCalledWith('wf-1', 'exec-1', undefined);
});
it('does not start if another execution begins while loading the workflow', async () => {
const { executeWorkflow, workflows } = harness();
const read = Promise.withResolvers<typeof workflow>();
vi.mocked(getWorkflow).mockReturnValueOnce(read.promise);
const pending = executeWorkflow();
const state = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
state.setActiveExecutionId(null);
read.resolve(workflow);
await expect(pending).resolves.toBeUndefined();
expect(workflows.runWorkflow).not.toHaveBeenCalled();
expect(state.activeExecutionId).toBeNull();
});
it('allows another workflow to execute while the first waits for a webhook', async () => {
const { executeWorkflow, workflowId, workflows, isRunning } = harness();
const otherWorkflow = createTestWorkflow({
id: 'wf-2',
nodes: [createTestNode({ name: 'Second start', type: 'n8n-nodes-base.manualTrigger' })],
});
vi.mocked(getWorkflow).mockImplementation(async (_context, id) =>
id === otherWorkflow.id ? otherWorkflow : workflow,
);
workflows.runWorkflow.mockResolvedValueOnce({ waitingForWebhook: true });
const first = executeWorkflow();
await flushPromises();
expect(workflows.runWorkflow).toHaveBeenNthCalledWith(1, {
workflowId: 'wf-1',
triggerToStartFrom: { name: 'Start' },
});
expect(isRunning.value).toBe(true);
workflowId.value = 'wf-2';
expect(isRunning.value).toBe(false);
workflows.runWorkflow.mockResolvedValueOnce({ executionId: 'exec-2' });
const second = executeWorkflow();
await flushPromises();
expect(workflows.runWorkflow).toHaveBeenCalledTimes(2);
expect(workflows.runWorkflow).toHaveBeenNthCalledWith(2, {
workflowId: 'wf-2',
triggerToStartFrom: { name: 'Second start' },
});
finish('success', 'exec-2', 'wf-2');
await expect(second).resolves.toMatchObject({
workflowId: 'wf-2',
executionId: 'exec-2',
notified: true,
});
expect(isRunning.value).toBe(false);
for (const handler of handlers)
handler({ type: 'testWebhookDeleted', data: { workflowId: 'wf-1' } });
await first;
});
it('does not start twice while a run is in progress', async () => {
const { executeWorkflow, workflows } = harness();
const pending = executeWorkflow();
await flushPromises();
await expect(executeWorkflow()).resolves.toBeUndefined();
expect(workflows.runWorkflow).toHaveBeenCalledOnce();
finish();
await pending;
});
it('cleans up when a pending webhook is canceled before an execution exists', async () => {
const { executeWorkflow, workflows, thread } = harness();
workflows.runWorkflow.mockResolvedValueOnce({ waitingForWebhook: true });
const pending = executeWorkflow();
await flushPromises();
for (const handler of handlers)
handler({ type: 'testWebhookDeleted', data: { workflowId: 'wf-1' } });
await expect(pending).resolves.toBeUndefined();
expect(
useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')).activeExecutionId,
).toBeUndefined();
expect(thread.sendMessage).not.toHaveBeenCalled();
expect(handlers.size).toBe(0);
});
it('keeps the original workflow target while waiting for its execution', async () => {
const { executeWorkflow, workflowId, thread } = harness();
const pending = executeWorkflow();
await flushPromises();
workflowId.value = 'wf-2';
finish();
await expect(pending).resolves.toMatchObject({ workflowId: 'wf-1' });
expect(thread.sendMessage).toHaveBeenCalledOnce();
});
it('cleans up without sending a completion message when its scope closes', async () => {
const { executeWorkflow, scope, thread } = harness();
const pending = executeWorkflow();
await flushPromises();
scope.stop();
await expect(pending).resolves.toBeUndefined();
expect(handlers.size).toBe(0);
expect(thread.sendMessage).not.toHaveBeenCalled();
});
it.each([false, true])(
'clears webhook waiting state when its scope closes before the response: %s',
async (closeBeforeResponse) => {
const { executeWorkflow, workflows, scope, thread } = harness();
const response = Promise.withResolvers<IExecutionPushResponse>();
workflows.runWorkflow.mockReturnValueOnce(response.promise);
const pending = executeWorkflow();
await flushPromises();
if (closeBeforeResponse) scope.stop();
response.resolve({ waitingForWebhook: true });
await flushPromises();
if (!closeBeforeResponse) scope.stop();
await expect(pending).resolves.toBeUndefined();
const state = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1'));
expect(state.activeExecutionId).toBeUndefined();
expect(state.executionWaitingForWebhook).toBe(false);
expect(thread.sendMessage).not.toHaveBeenCalled();
},
);
it('does not announce an execution when starting it fails', async () => {
const { executeWorkflow, workflows, thread } = harness();
workflows.runWorkflow.mockRejectedValueOnce(new Error('Start failed'));
await expect(executeWorkflow()).rejects.toThrow('Start failed');
expect(
useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')).activeExecutionId,
).toBeUndefined();
expect(handlers.size).toBe(0);
expect(thread.sendMessage).not.toHaveBeenCalled();
});
it('does not rerun automatically when the notification fails', async () => {
const { executeWorkflow, workflows, thread } = harness();
thread.sendMessage.mockResolvedValueOnce(false);
const pending = executeWorkflow();
await flushPromises();
finish();
await expect(pending).resolves.toMatchObject({ status: 'success', notified: false });
expect(workflows.runWorkflow).toHaveBeenCalledOnce();
});
it.each([false, true])('selects an eligible trigger, selected disabled: %s', async (disabled) => {
const { executeWorkflow, workflows } = harness();
vi.mocked(getWorkflow).mockResolvedValueOnce({
...workflow,
nodes: [
workflow.nodes[0],
createTestNode({ name: 'Selected', type: 'n8n-nodes-base.manualTrigger', disabled }),
],
});
useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')).setSelectedTriggerNodeName(
'Selected',
);
const pending = executeWorkflow();
await flushPromises();
expect(workflows.runWorkflow).toHaveBeenCalledWith({
workflowId: 'wf-1',
triggerToStartFrom: { name: disabled ? 'Start' : 'Selected' },
});
finish();
await pending;
});
it.each(['missing', 'disabled'])(
'does not execute when eligible triggers are %s',
async (reason) => {
const { executeWorkflow, workflows, thread } = harness();
vi.mocked(getWorkflow).mockResolvedValueOnce({
...workflow,
nodes: reason === 'missing' ? [] : [{ ...workflow.nodes[0], disabled: true }],
});
await expect(executeWorkflow()).rejects.toThrow(/trigger node/i);
expect(workflows.runWorkflow).not.toHaveBeenCalled();
expect(thread.sendMessage).not.toHaveBeenCalled();
},
);
});

View File

@ -47,9 +47,12 @@ function createHarness(
const available = ref(workflowAvailable);
const derived = ref(derivedItems);
const doneIds = ref(new Set<string>());
const refreshing = ref(false);
const refreshWorkflow = vi.fn().mockResolvedValue(undefined);
vi.mocked(isAgentEditingWorkflow).mockImplementation(() => editing.value);
vi.mocked(useWorkflowSetupItems).mockReturnValue({
credentialsAvailable: computed(() => true),
isWorkflowAvailable: computed(() => available.value),
workflowProjectId: computed(() => undefined),
derivedItems: computed(() => derived.value),
@ -57,8 +60,10 @@ function createHarness(
derived.value.filter((item) => item.kind === 'credential'),
),
isItemDone: (item: InstanceAiSetupItem) => doneIds.value.has(item.id),
isCredentialConfigured: vi.fn().mockReturnValue(false),
isRefreshingWorkflow: refreshing,
getNodeByName: vi.fn(),
refreshWorkflow: vi.fn().mockResolvedValue(undefined),
refreshWorkflow,
});
const thread: SetupPanelThreadSource = reactive({
@ -67,7 +72,7 @@ function createHarness(
});
const state = useSetupPanelState({ thread, workflowId: () => workflowId });
return { state, editing, available, doneIds, derived };
return { state, editing, available, doneIds, derived, refreshing, refreshWorkflow, thread };
}
describe('useSetupPanelState', () => {
@ -137,6 +142,32 @@ describe('useSetupPanelState', () => {
expect(state.rows.value).toEqual([]);
});
it('keeps a recipe omitted from a later snapshot only while its workflow node remains', () => {
const item: InstanceAiSetupItem = {
id: `${WORKFLOW_ID}:credential:httpTemplatedCustomAuth:Fetch notes`,
kind: 'credential',
credentialType: 'httpTemplatedCustomAuth',
nodeBindings: [{ nodeName: 'Fetch notes' }],
};
const setupHint = {
template: { headers: { Authorization: 'Bearer {{api_key}}' } },
placeholders: [{ name: 'api_key', title: 'API key' }],
suggestedName: 'Notes service',
};
const { state, thread, derived } = createHarness({
agentEditing: true,
workflowAvailable: true,
eventItems: [{ ...item, setupHint }],
derivedItems: [item],
});
thread.setupItemsByWorkflowId[WORKFLOW_ID] = [item];
expect(state.rows.value[0].item).toMatchObject({ setupHint });
thread.setupItemsByWorkflowId[WORKFLOW_ID] = [];
expect(state.rows.value[0].item).toMatchObject({ setupHint });
derived.value = [];
expect(state.rows.value).toEqual([]);
});
it('does not share a generic credential recipe or binding with another node', () => {
const announcement: InstanceAiSetupItem = {
id: `${WORKFLOW_ID}:credential:httpTemplatedCustomAuth:First service`,
@ -203,6 +234,19 @@ describe('useSetupPanelState', () => {
expect(toValue(options?.paused)).toBe(false);
});
it('refreshes announced snapshots during a build', () => {
const { state, thread, refreshing, refreshWorkflow } = createHarness({ agentEditing: true });
expect(refreshWorkflow).toHaveBeenCalledExactlyOnceWith({ force: true });
refreshing.value = true;
expect(state.isRefreshingWorkflow.value).toBe(true);
refreshing.value = false;
expect(state.isRefreshingWorkflow.value).toBe(false);
thread.setupItemsByWorkflowId[WORKFLOW_ID] = [
{ ...eventItem, nodeBindings: [{ nodeName: 'Slack' }] },
];
expect(refreshWorkflow).toHaveBeenCalledTimes(2);
});
it('reads no event items for a workflowId matching a prototype property', () => {
const { state } = createHarness({ workflowId: 'constructor' });

View File

@ -0,0 +1,170 @@
import { effectScope, nextTick, ref } from 'vue';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type { SetupPanelRow } from '../composables/useSetupPanelState';
import type { SetupPanelGroup } from '../setupPanelGroups';
import { useSetupPanelTelemetry } from '../composables/useSetupPanelTelemetry';
const { track } = vi.hoisted(() => ({ track: vi.fn() }));
vi.mock('@n8n/composables/useTelemetry', () => ({ useTelemetry: () => ({ track }) }));
const item = {
id: 'wf:credential:slackApi',
kind: 'credential',
credentialType: 'slackApi',
} as const;
const observed = TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_STATE_OBSERVED;
const shown = TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_ITEM_SHOWN;
const dismissed = TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_DISMISSED;
describe('useSetupPanelTelemetry', () => {
const scopes: ReturnType<typeof effectScope>[] = [];
beforeEach(() => track.mockClear());
afterEach(() => {
scopes.splice(0).forEach((scope) => scope.stop());
});
function setup(thread = { id: 'thread' }) {
const workflowId = ref('wf');
const rows = ref<SetupPanelRow[]>([{ item, isDone: true }]);
const groups = ref<SetupPanelGroup[]>([]);
const ready = ref(false);
const shownItemIds = ref<string[]>([]);
const scope = effectScope();
scopes.push(scope);
const telemetry = scope.run(() =>
useSetupPanelTelemetry({ workflowId, thread, rows, groups, ready, shownItemIds }),
)!;
return { workflowId, rows, groups, ready, shownItemIds, telemetry, scope };
}
it('shares impressions across overlapping views without a false navigation dismissal', async () => {
const thread = { id: 'thread' };
const first = setup(thread);
first.groups.value = [{ id: item.id, credential: { item, isDone: true }, parameters: [] }];
first.ready.value = true;
await nextTick();
const second = setup(thread);
second.groups.value = first.groups.value;
second.ready.value = true;
await nextTick();
first.scope.stop();
await nextTick();
expect(track.mock.calls.filter(([event]) => event === shown)).toHaveLength(1);
expect(track.mock.calls.filter(([event]) => event === observed)).toHaveLength(1);
expect(track.mock.calls.filter(([event]) => event === dismissed)).toHaveLength(0);
second.scope.stop();
expect(track.mock.calls.filter(([event]) => event === dismissed)).toHaveLength(1);
});
it('updates connected counts when another tab changes shown item IDs', async () => {
const state = setup();
state.ready.value = true;
await nextTick();
state.shownItemIds.value = [item.id];
await nextTick();
expect(track).toHaveBeenLastCalledWith(
observed,
expect.objectContaining({ already_connected_count: 0 }),
);
expect(track.mock.calls.filter(([event]) => event === observed)).toHaveLength(2);
});
it('counts already-connected services without an impression and deduplicates equivalent state', async () => {
const state = setup();
expect(track).not.toHaveBeenCalled();
state.ready.value = true;
await nextTick();
expect(track).toHaveBeenCalledExactlyOnceWith(observed, {
workflow_id: 'wf',
thread_id: 'thread',
credential_count: 1,
pending_credential_count: 0,
pending_parameter_count: 0,
already_connected_count: 1,
});
state.rows.value = [{ item: { ...item }, isDone: true }];
await nextTick();
expect(track).toHaveBeenCalledTimes(1);
});
it('records each visible item once and distinguishes removal from navigation', async () => {
const state = setup();
state.ready.value = true;
state.rows.value = [{ item, isDone: false }];
state.shownItemIds.value = [item.id];
state.groups.value = [{ id: item.id, credential: { item, isDone: false }, parameters: [] }];
await nextTick();
state.rows.value = [{ item, isDone: true }];
state.groups.value = [{ id: item.id, credential: { item, isDone: true }, parameters: [] }];
await nextTick();
expect(track.mock.calls.filter(([name]) => name === shown)).toHaveLength(1);
expect(track).toHaveBeenLastCalledWith(
observed,
expect.objectContaining({ already_connected_count: 0, pending_credential_count: 0 }),
);
state.groups.value = [];
await nextTick();
expect(track).toHaveBeenLastCalledWith(dismissed, {
workflow_id: 'wf',
thread_id: 'thread',
reason: 'items_removed',
});
state.scope.stop();
expect(track.mock.calls.filter(([name]) => name === dismissed)).toHaveLength(1);
});
it('attributes a completed connection to its original workflow after navigation', async () => {
const state = setup();
state.ready.value = true;
state.groups.value = [{ id: item.id, credential: { item, isDone: false }, parameters: [] }];
await nextTick();
state.telemetry.trackConnectionStarted(item, 'oauth');
state.workflowId.value = 'other';
state.groups.value = [];
await nextTick();
expect(track).toHaveBeenCalledWith(
dismissed,
expect.objectContaining({ workflow_id: 'wf', reason: 'navigation' }),
);
state.telemetry.trackConnectionCompleted(item, 'credential', 'error');
state.telemetry.trackConnectionCompleted(item, 'credential', 'applied');
state.telemetry.trackConnectionCompleted(item, 'credential', 'applied');
const completions = track.mock.calls.filter(
([name]) => name === TELEMETRY_EVENT.CREDENTIALS.USER_COMPLETED_CREDENTIAL_CONNECTION,
);
expect(completions).toEqual([
[
TELEMETRY_EVENT.CREDENTIALS.USER_COMPLETED_CREDENTIAL_CONNECTION,
{
workflow_id: 'wf',
thread_id: 'thread',
source: 'instance_ai_setup_panel',
credential_type: 'slackApi',
credential_id: 'credential',
method: 'oauth',
binding_state: 'applied',
},
],
]);
});
it('updates the observed count when a pending row gains another required parameter', async () => {
const state = setup();
state.ready.value = true;
const parameters = {
id: 'wf:parameters:Send',
kind: 'parameters',
nodeName: 'Send',
parameterNames: ['channel'],
} as const;
state.rows.value = [{ item: { ...parameters, parameterNames: ['channel'] }, isDone: false }];
await nextTick();
state.rows.value = [
{ item: { ...parameters, parameterNames: ['channel', 'message'] }, isDone: false },
];
await nextTick();
expect(track).toHaveBeenLastCalledWith(
observed,
expect.objectContaining({ pending_parameter_count: 2 }),
);
});
});

View File

@ -240,12 +240,28 @@ const WORKFLOW_LOCKING_TOOLS = new Set([
* restore-version / setup action. Read-only `workflows` actions (including
* historical get-json events, get, list, …) don't lock.
*/
export function isAgentEditingWorkflow(node: InstanceAiAgentNode, workflowId: string): boolean {
export function isAgentEditingWorkflow(
node: InstanceAiAgentNode,
workflowId: string,
announcement = node.latestSetupAnnouncement,
): boolean {
const announcedBuild =
announcement?.workflowId === workflowId &&
node.toolCalls.some(
(call) =>
call.isLoading &&
call.toolName === 'build-workflow' &&
!call.args?.workflowId &&
announcement.agentId === node.agentId &&
call.startedAt &&
announcement.timestamp >= call.startedAt,
);
if (
node.status === 'active' &&
(getLatestBuildResult(node)?.workflowId === workflowId ||
getLatestWorkflowSetupResult(node)?.workflowId === workflowId ||
getLatestWorkflowUpdateResult(node)?.workflowId === workflowId)
getLatestWorkflowUpdateResult(node)?.workflowId === workflowId ||
announcedBuild)
) {
return true;
}
@ -277,7 +293,7 @@ export function isAgentEditingWorkflow(node: InstanceAiAgentNode, workflowId: st
}
for (const child of node.children) {
if (isAgentEditingWorkflow(child, workflowId)) return true;
if (isAgentEditingWorkflow(child, workflowId, announcement)) return true;
}
return false;
}

View File

@ -3,6 +3,7 @@ import { N8nButton, N8nCard, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import type { InstanceAiConfirmation, InstanceAiConfirmRequest } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { redactTelemetryProperties } from '@n8n/telemetry';
import { computed, ref } from 'vue';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -39,6 +40,7 @@ const props = defineProps<Props>();
const thread = useThread();
const i18n = useI18n();
const rootStore = useRootStore();
const settingsStore = useInstanceAiSettingsStore();
const telemetry = useTelemetry();
const { getToolLabel } = useToolLabel();
@ -651,7 +653,18 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
<N8nText size="large" bold>
{{ buildApprovalTitle(chunk.item) }}
</N8nText>
<N8nText
v-if="
settingsStore.isInstanceAiSetupPanelEnabled &&
chunk.item.toolCall.confirmation.credentialDestination
"
tag="p"
size="small"
:class="$style.credentialDescription"
>{{ buildApprovalSubtitle(chunk.item) }}</N8nText
>
<ConfirmationPreview
v-else
:class="$style.approvalDescription"
role="region"
:aria-label="i18n.baseText('instanceAi.confirmation.details')"
@ -733,6 +746,12 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
padding: var(--spacing--sm) var(--spacing--sm) 0;
}
.credentialDescription {
margin: 0;
overflow-wrap: anywhere;
word-break: normal;
}
.textInputRow {
display: flex;
align-items: center;

View File

@ -73,6 +73,8 @@ import CreditWarningBanner from '@/features/ai/assistant/components/Agent/Credit
const props = defineProps<{
/** Runs before every send (e.g. flush a pending autosave). Rejecting cancels the send. */
beforeSend?: () => Promise<void>;
/** Extra scroll space for a panel that overlays messages above the input. */
aboveInputOverlapHeight?: number;
}>();
const emit = defineEmits<{
@ -251,6 +253,8 @@ const workflowHandoffAttachment = computed(() => {
// --- Scroll management ---
const scrollableRef = useTemplateRef<HTMLElement>('scrollable');
const messageListRef = useTemplateRef<HTMLElement>('messageList');
const inputDockRef = useTemplateRef<HTMLElement>('inputDock');
// The actual scroll container is the reka-ui viewport inside N8nScrollArea,
// NOT the immediate parent (which is a non-scrolling content wrapper).
const scrollContainerRef = computed(
@ -286,16 +290,19 @@ function scrollToBottom(smooth = false) {
let contentResizeObserver: ResizeObserver | null = null;
watch(
scrollableRef,
(el) => {
[messageListRef, inputDockRef],
(elements) => {
contentResizeObserver?.disconnect();
if (el) {
if (elements.some(Boolean)) {
contentResizeObserver = new ResizeObserver(() => {
if (!userScrolledUp.value) {
scrollToBottom();
}
});
contentResizeObserver.observe(el);
// Extra setup clearance changes the scroll range without moving the messages.
for (const element of elements) {
if (element) contentResizeObserver.observe(element);
}
}
},
{ immediate: true },
@ -706,8 +713,12 @@ defineExpose({
<template>
<div :class="$style.chatContent">
<N8nScrollArea as-child type="auto" :class="$style.scrollArea">
<div ref="scrollable" :class="$style.scrollContent">
<div :class="$style.messageList">
<div
ref="scrollable"
:class="$style.scrollContent"
:style="{ overflowAnchor: aboveInputOverlapHeight !== undefined ? 'none' : undefined }"
>
<div ref="messageList" :class="$style.messageList">
<!-- Mirrors the old empty opener: a user bubble with only the
workflow chip, then the static assistant greeting. -->
<N8nChatMessage
@ -763,7 +774,12 @@ defineExpose({
anchored above the slot in both states. The leaving child is
positioned absolutely during the cross-fade so the in-flow child
can size the slot to its natural height. -->
<div :class="$style.inputDock">
<div
v-if="aboveInputOverlapHeight"
:style="{ minHeight: `${aboveInputOverlapHeight}px` }"
data-test-id="setup-scroll-clearance"
/>
<div ref="inputDock" :class="$style.inputDock">
<!-- Scroll to bottom button -->
<div :class="$style.scrollButtonContainer">
<Transition name="scroll-button-fade">

View File

@ -26,6 +26,7 @@ import { useIsAgentWorking } from '../composables/useIsAgentWorking';
import { useInstanceAiWorkflowPreviewExecution } from '../composables/useInstanceAiWorkflowPreviewExecution';
import type { FixWithAiError } from '../fixWithAi';
import { useThread } from '../instanceAi.store';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
export interface WorkflowFailuresReport {
workflowId: string;
@ -139,6 +140,7 @@ function handleWorkflowLoaded(workflowId: string) {
// the canvas editable through workspace file edits and failed builds.
const thread = useThread();
const isAgentWorking = useIsAgentWorking();
const settingsStore = useInstanceAiSettingsStore();
// The workflow + execution the editor handed off, applied once when this
// preview first opens. Consumed (cleared) here, so it never re-applies on a
@ -173,6 +175,7 @@ const enabledFeatures = computed<EditorEnabledFeatures>(() => ({
executionSuccessToasts: false,
executionErrorToasts: false,
executionButtonType: 'secondary',
credentialSetupWarnings: settingsStore.isInstanceAiSetupPanelEnabled,
}));
provide(EditorEnabledFeaturesKey, enabledFeatures);

View File

@ -0,0 +1,751 @@
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import isEqual from 'lodash/isEqual';
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '@n8n/api-types';
import {
N8nButton,
N8nCallout,
N8nCopyInput,
N8nDropdownMenu,
N8nIcon,
N8nSegmentControl,
N8nSetupConnection,
N8nText,
} from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import { addCredentialTranslation, useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@n8n/composables/useToast';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import {
deepCopy,
DOMAIN_RESTRICTION_FIELDS,
type ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import type { INodeUi, INodeUpdatePropertiesInformation, IUpdateInformation } from '@/Interface';
import { AI_GATEWAY_UNSUPPORTED_NODE_TYPES, BUILTIN_CREDENTIALS_DOCS_URL } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useAiGateway } from '@/app/composables/useAiGateway';
import { useAiGatewayTopUp } from '@/app/composables/useAiGatewayTopUp';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import type { InstanceAiCredentialContext } from '@/app/composables/useInstanceAiEditorCapability';
import { useCredentialForm } from '@/features/credentials/composables/useCredentialForm';
import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth';
import { useQuickConnect } from '@/features/credentials/quickConnect/composables/useQuickConnect';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
import TemplatedAuthSimpleView from '@/features/credentials/components/CredentialEdit/TemplatedAuthSimpleView.vue';
import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue';
import CredentialsDropdown from '@/features/credentials/components/CredentialPicker/CredentialsDropdown.vue';
import {
deriveServiceName,
listPlaceholderTitles,
} from '@/features/credentials/templatedAuth.utils';
import { getAppNameFromCredType, getAppNameFromNodeName } from '@/app/utils/nodeTypesUtils';
import type {
SetupCredentialItem,
SetupCredentialRef,
} from '../../composables/useSetupPanelActions';
import { useSetupPanelDocument } from '../../composables/useSetupPanelDocument';
import type { SetupPanelConnectionMethod } from '../../composables/useSetupPanelTelemetry';
import { AI_GATEWAY_MANAGED_TAG } from '../../constants';
const props = defineProps<{
item: SetupCredentialItem;
node?: INodeUi;
/** Saved credential selected while its workflow binding is queued or being applied. */
pendingCredential?: SetupCredentialRef;
nodes: INodeUi[];
workflowId: string;
projectId: string;
/** Whether the assistant is busy with another request. */
helpDisabled?: boolean;
/** Allow the shared account to be configured separately for each node. */
allowPerNode?: boolean;
}>();
const emit = defineEmits<{
bindCredential: [item: SetupCredentialItem, credentialId: string];
connectStarted: [method: SetupPanelConnectionMethod];
askForHelp: [credential: InstanceAiCredentialContext];
setCredentialsPerNode: [];
'update:busy': [value: boolean];
'update:hasChanges': [value: boolean];
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const uiStore = useUIStore();
const toast = useToast();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const credentialsStore = useCredentialsStore();
const oauth = useCredentialOAuth();
const quickConnect = useQuickConnect();
const gateway = useAiGateway();
const { openTopUp } = useAiGatewayTopUp();
const initialized = ref(false);
const initializationFailed = ref(false);
const busy = ref(false);
const createNew = ref(false);
const hasDraft = ref(false);
watch(busy, (value) => emit('update:busy', value));
watch(
() => hasDraft.value || createNew.value,
(value) => emit('update:hasChanges', value),
);
const chooseExisting = ref(false);
const mode = ref<'credits' | 'own'>('credits');
const modeChanged = ref(false);
const usesCustomOAuth = ref(false);
let active = true;
useSetupPanelDocument({
workflowId: () => props.workflowId,
itemId: () => props.item.id,
node: () => props.node,
});
const form = useCredentialForm({
mode: 'new',
activeId: () => props.item.credentialType,
contextNode: () => props.node ?? null,
projectId: () => props.projectId,
setupHint: () => props.item.setupHint,
});
form.setCredentialPropertyDefaults();
const isOAuth = form.isOAuthType;
const isTemplated = computed(
() => props.item.credentialType === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
);
const canQuickConnect = computed(
() =>
(isOAuth.value && oauth.canOAuthCredentialQuickConnect(props.item.credentialType)) ||
Boolean(
!oauth.isOAuthCredentialType(props.item.credentialType) &&
props.node &&
quickConnect.getQuickConnectOption(props.item.credentialType, props.node.type),
),
);
const serviceName = computed(
() =>
deriveServiceName(props.item.setupHint) ??
getAppNameFromNodeName(
getAppNameFromCredType(
props.item.appDisplayName ??
form.credentialType.value?.displayName ??
props.item.credentialType,
),
),
);
const binding = computed(() => {
if (props.pendingCredential) return props.pendingCredential;
const value = props.node?.credentials?.[props.item.credentialType];
return value && typeof value !== 'string' ? value : undefined;
});
const connected = computed(
() =>
!createNew.value &&
!hasDraft.value &&
Boolean(binding.value?.id || binding.value?.__aiGatewayManaged),
);
watch(
() => (isOAuth.value && canQuickConnect.value ? binding.value?.id : undefined),
async (id) => {
usesCustomOAuth.value = false;
if (!id) return;
try {
const credential = await credentialsStore.getCredentialData({ id });
if (!active || binding.value?.id !== id) return;
const data = credential?.data;
usesCustomOAuth.value = Boolean(
typeof data === 'object' && data?.clientId && data?.clientSecret,
);
} catch {
// Shared credentials can be usable without permission to read their data.
}
},
{ immediate: true },
);
const usableCredentials = computed(() =>
credentialsStore.hasUsableCredentialsForScope({ workflowId: props.workflowId })
? credentialsStore.getUsableCredentialByType(props.item.credentialType)
: [],
);
const storedCredential = computed(() =>
binding.value?.id
? (usableCredentials.value.find(({ id }) => id === binding.value?.id) ??
credentialsStore.getCredentialById(binding.value.id))
: undefined,
);
const showExistingPicker = computed(
() =>
chooseExisting.value ||
storedCredential.value?.isResolvable ||
(!connected.value && !createNew.value && !hasDraft.value && usableCredentials.value.length > 0),
);
const gatewayAvailable = computed(
() =>
gateway.isEnabled.value &&
gateway.isCredentialTypeSupported(props.item.credentialType) &&
props.nodes.length > 0 &&
props.nodes.every((node) => {
const { operation, resource } = node.parameters;
return (
!AI_GATEWAY_UNSUPPORTED_NODE_TYPES.includes(node.type) &&
gateway.isNodeTypeVersionSupported(node.type, node.typeVersion) &&
(typeof operation !== 'string' ||
!operation ||
gateway.isActionSupported(
node.type,
typeof resource === 'string' ? resource : undefined,
operation,
))
);
}),
);
watch(
[gatewayAvailable, gateway.balance, usableCredentials],
([available, balance, credentials]) => {
if (available && !modeChanged.value && !connected.value)
mode.value = (balance ?? 0) > 0 || credentials.length === 0 ? 'credits' : 'own';
},
{ immediate: true },
);
const inlineFields = computed(() => {
const fields = form.credentialProperties.value.filter(
(property) =>
property.type !== 'hidden' &&
property.type !== 'notice' &&
!property.typeOptions?.copyButton &&
!DOMAIN_RESTRICTION_FIELDS.some(({ name }) => name === property.name),
);
const required = fields.filter((property) => property.required);
// Older credential definitions can omit required flags even for access tokens.
const inputs = required.length ? required : fields.filter((property) => !property.default);
return inputs.length <= 2 ? inputs : [];
});
const fieldTitles = computed(() =>
isTemplated.value
? listPlaceholderTitles(form.credentialData.value)
: inlineFields.value.map((property) => property.displayName),
);
const useAdvancedForm = computed(() => !canQuickConnect.value && fieldTitles.value.length === 0);
const advancedIsPrimary = computed(() => isTemplated.value && useAdvancedForm.value);
const valueLabel = computed(() =>
binding.value?.__aiGatewayManaged
? i18n.baseText('instanceAi.setupPanel.connectedWith')
: isOAuth.value
? i18n.baseText('instanceAi.setupPanel.account')
: i18n.baseText('instanceAi.setupPanel.credential'),
);
const balanceLabel = computed(() =>
gateway.balance.value === undefined
? ''
: gateway.balance.value <= 0
? i18n.baseText('aiGateway.wallet.noCredits')
: i18n.baseText('aiGateway.wallet.balanceRemaining', {
interpolate: { balance: `$${Number(gateway.balance.value).toFixed(2)}` },
}),
);
const value = computed(() =>
binding.value?.__aiGatewayManaged
? [i18n.baseText('generic.n8nCredits'), balanceLabel.value].filter(Boolean).join(' · ')
: isOAuth.value
? (storedCredential.value?.connectedAccountIdentifier ??
storedCredential.value?.name ??
binding.value?.name)
: (storedCredential.value?.name ?? binding.value?.name),
);
const useCredits = computed(() => gatewayAvailable.value && mode.value === 'credits');
const actionLabel = computed(() =>
useCredits.value
? i18n.baseText('instanceAi.setupPanel.useCredits')
: advancedIsPrimary.value
? i18n.baseText('instanceAi.setupPanel.advancedSetup')
: canQuickConnect.value || useAdvancedForm.value
? i18n.baseText('instanceAi.setupPanel.connect')
: isOAuth.value
? i18n.baseText('instanceAi.setupPanel.saveAndSignIn')
: i18n.baseText('generic.save'),
);
const actionDisabled = computed(
() =>
!initialized.value ||
(!useCredits.value &&
!canQuickConnect.value &&
!useAdvancedForm.value &&
!form.requiredPropertiesFilled.value),
);
const redirectUrl = computed(() => {
const urls = rootStore.OAuthCallbackUrls;
if (form.parentTypes.value.includes('oAuth1Api') || props.item.credentialType === 'oAuth1Api') {
return 'oauth1' in urls && typeof urls.oauth1 === 'string' ? urls.oauth1 : '';
}
return 'oauth2' in urls && typeof urls.oauth2 === 'string' ? urls.oauth2 : '';
});
const documentationUrl = computed(() => {
const url = props.item.setupHint?.docsUrl ?? form.credentialType.value?.documentationUrl;
if (!url) return '';
return /^https?:\/\//.test(url) ? url : `${BUILTIN_CREDENTIALS_DOCS_URL}${url}/`;
});
const modeOptions = computed<Array<{ value: 'credits' | 'own'; label: string }>>(() => [
{ value: 'credits', label: i18n.baseText('generic.n8nCredits') },
{ value: 'own', label: i18n.baseText('instanceAi.setupPanel.useOwnKey') },
]);
const perNodeActions = computed<DropdownMenuItemProps[]>(() =>
props.allowPerNode && connected.value
? [{ id: 'per-node', label: i18n.baseText('instanceAi.setupPanel.setCredentialsPerNode') }]
: [],
);
const actions = computed<DropdownMenuItemProps[]>(() => {
const existing = usableCredentials.value.some(
(credential) => !connected.value || credential.id !== binding.value?.id,
)
? [{ id: 'existing', label: i18n.baseText('instanceAi.setupPanel.useExisting') }]
: [];
if (!connected.value && useCredits.value) return existing;
if (connected.value && binding.value?.__aiGatewayManaged)
return [
{ id: 'replace', label: i18n.baseText('instanceAi.setupPanel.useOwnKey') },
{ id: 'manage', label: i18n.baseText('instanceAi.setupPanel.manageCredits') },
...perNodeActions.value,
];
const items: DropdownMenuItemProps[] = connected.value
? [
{
id: 'replace',
label: i18n.baseText(
isOAuth.value
? usesCustomOAuth.value
? 'instanceAi.setupPanel.connectWithOAuth'
: 'instanceAi.setupPanel.switchAccount'
: 'instanceAi.setupPanel.replaceKey',
),
},
{ id: 'edit', label: i18n.baseText('instanceAi.setupPanel.editCredential') },
]
: advancedIsPrimary.value
? []
: [{ id: 'advanced', label: i18n.baseText('instanceAi.setupPanel.advancedSetup') }];
return [...items, ...existing, ...perNodeActions.value];
});
const helpLabel = computed(() => {
const fieldName = inlineFields.value.length === 1 ? inlineFields.value[0].name : undefined;
return i18n.baseText(
fieldName === 'apiKey'
? 'instanceAi.setupPanel.helpFindApiKey'
: fieldName === 'accessToken'
? 'instanceAi.setupPanel.helpFindAccessToken'
: 'instanceAi.setupPanel.helpSetUp',
);
});
function askForHelp() {
if (props.helpDisabled || busy.value) return;
emit('askForHelp', {
credentialType: props.item.credentialType,
displayName: serviceName.value,
nodeName: props.node?.name,
nodeType: props.node?.type,
placeholderTitles: isOAuth.value ? undefined : fieldTitles.value,
documentationUrl: documentationUrl.value || undefined,
oauthRedirectUrl: isOAuth.value ? redirectUrl.value : undefined,
setupContext: isOAuth.value
? [
fieldTitles.value.length
? i18n.baseText('instanceAi.setupPanel.helpVisibleFields', {
interpolate: { fields: fieldTitles.value.join(', ') },
})
: '',
i18n.baseText(
canQuickConnect.value
? 'instanceAi.setupPanel.helpManagedOAuthAvailable'
: 'instanceAi.setupPanel.helpManagedOAuthUnavailable',
),
]
.filter(Boolean)
.join(' ')
: undefined,
});
}
function emitBinding(id: string, submittedData?: ICredentialDataDecryptedObject) {
if (!active) return;
createNew.value = false;
// A completed save must not discard edits made while the request was pending.
hasDraft.value = submittedData !== undefined && !isEqual(formData(), submittedData);
chooseExisting.value = false;
emit('bindCredential', props.item, id);
}
function onCredentialSelected(update: INodeUpdatePropertiesInformation) {
const credential = update.properties.credentials?.[props.item.credentialType];
if (!credential || typeof credential === 'string') return;
emit('connectStarted', 'existing');
if (credential.__aiGatewayManaged) emitBinding(AI_GATEWAY_MANAGED_TAG);
else if (credential.id) emitBinding(credential.id);
}
function formData(): ICredentialDataDecryptedObject {
const data = { ...form.credentialData.value };
delete data.homeProject;
delete data.sharedWithProjects;
return data;
}
function onDataChange(update: IUpdateInformation) {
if (form.onDataChange(update)) hasDraft.value = true;
}
async function saveKey() {
const workflowId = props.workflowId;
const details = {
id: '',
name: form.credentialName.value,
type: props.item.credentialType,
data: deepCopy(formData()),
};
const credential = await credentialsStore.createNewCredential(
details,
props.projectId,
undefined,
{
skipStoreUpdate: true,
},
);
await externalHooks.run('credential.saved', {
credential_type: credential.type,
credential_id: credential.id,
is_new: true,
});
telemetry.track('User created credentials', {
credential_type: credential.type,
credential_id: credential.id,
workflow_id: props.workflowId,
});
if (form.isCredentialTestable.value) {
await form.testCredential({ ...details, id: credential.id });
if (!form.testedSuccessfully.value) {
await credentialsStore.deleteCredential({ id: credential.id });
return;
}
}
credentialsStore.upsertCredential(credential);
if (!active || props.workflowId !== workflowId) return;
// Creation succeeded. A picker refresh must not force another credential creation.
await credentialsStore.fetchUsableCredentials({ workflowId }).catch(() => {});
emitBinding(credential.id, details.data);
}
async function connect() {
if (busy.value || actionDisabled.value) return;
busy.value = true;
form.authError.value = '';
try {
if (useCredits.value) {
emit('connectStarted', 'gateway');
emitBinding(AI_GATEWAY_MANAGED_TAG);
} else if (useAdvancedForm.value) {
onMenuAction('advanced');
} else if (isOAuth.value) {
emit('connectStarted', 'oauth');
const submittedData = canQuickConnect.value ? undefined : deepCopy(formData());
const credential = await oauth.createAndAuthorize(
props.item.credentialType,
props.node?.type,
{
projectId: props.projectId,
workflowId: props.workflowId,
data: submittedData,
name: form.credentialName.value || undefined,
},
);
if (credential) emitBinding(credential.id, submittedData);
} else if (canQuickConnect.value && props.node) {
emit('connectStarted', 'api_key');
const credential = await quickConnect.connect({
credentialTypeName: props.item.credentialType,
nodeType: props.node.type,
serviceName: serviceName.value,
source: 'credential_type',
projectId: props.projectId,
workflowId: props.workflowId,
});
if (credential) emitBinding(credential.id);
} else {
emit('connectStarted', 'api_key');
await saveKey();
}
} catch (error) {
toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
} finally {
busy.value = false;
}
}
function onMenuAction(id: string) {
if (id === 'per-node') {
emit('setCredentialsPerNode');
return;
}
if (id === 'existing') {
chooseExisting.value = true;
return;
}
if (id === 'manage') {
void openTopUp({ source: 'credential_selector', credentialType: props.item.credentialType });
return;
}
if (id === 'edit' && binding.value?.id) {
uiStore.openExistingCredential(binding.value.id, { workflowId: props.workflowId });
return;
}
if (id === 'replace') {
mode.value = 'own';
modeChanged.value = true;
if (isOAuth.value && canQuickConnect.value) void connect();
else {
createNew.value = true;
if (!hasDraft.value) {
initialized.value = false;
void initialize();
}
}
return;
}
emit('connectStarted', 'advanced');
uiStore.openNewCredential(
props.item.credentialType,
false,
true,
props.projectId,
undefined,
props.node?.name,
props.node,
{
closeOnSave: true,
credentialSetupHint: props.item.setupHint,
workflowId: props.workflowId,
onCredentialCreated: (credential) => emitBinding(credential.id),
},
);
}
async function initialize() {
initializationFailed.value = false;
try {
uiStore.activeCredentialType = props.item.credentialType;
const initialization = form.initialize();
const type = props.item.credentialType;
const locale = rootStore.defaultLocale;
if (locale !== 'en' && !i18n.exists(`n8n-nodes-base.credentials.${type}`)) {
const translation = await credentialsStore
.getCredentialTranslation(type)
.catch(() => undefined);
if (translation) addCredentialTranslation({ [type]: translation }, locale);
}
await initialization;
initialized.value = true;
} catch (error) {
initializationFailed.value = true;
if (active) toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
}
}
if (connected.value) initialized.value = true;
else void initialize();
watch(
() => props.item.setupHint,
(hint) => {
if (hint && !connected.value) void initialize();
},
);
onMounted(() => {
void Promise.all([gateway.fetchConfig(), gateway.fetchWallet()]).catch(() => {});
});
onScopeDispose(() => {
active = false;
emit('update:busy', false);
emit('update:hasChanges', false);
oauth.cancelAuthorize();
quickConnect.cancelConnect();
});
</script>
<template>
<div :class="$style.form">
<template v-if="showExistingPicker">
<NodeCredentials
v-if="node"
:class="$style.existing"
:node="node"
:override-cred-type="item.credentialType"
:project-id="projectId"
:workflow-id="workflowId"
:credential-setup-hint="item.setupHint"
:credentials-field-label="i18n.baseText('instanceAi.setupPanel.credential')"
standalone
hide-issues
skip-auto-select
@credential-selected="onCredentialSelected"
@connection-started="emit('connectStarted', 'oauth')"
@connection-completed="emitBinding($event)"
>
<template v-if="perNodeActions.length" #label-postfix>
<N8nDropdownMenu
:items="perNodeActions"
:modal="false"
placement="bottom-end"
@select="onMenuAction"
>
<template #trigger>
<N8nButton
variant="ghost"
size="xsmall"
icon-only
:disabled="busy"
:aria-label="i18n.baseText('node.moreActions')"
>
<N8nIcon icon="pencil" size="small" />
</N8nButton>
</template>
</N8nDropdownMenu>
</template>
</NodeCredentials>
<CredentialsDropdown
v-else
teleported
:credential-options="
usableCredentials.map(({ id, name, homeProject }) => ({
id,
name,
homeProject,
typeDisplayName: form.credentialType.value?.displayName,
}))
"
:selected-credential-id="null"
:permissions="form.credentialPermissions.value"
@credential-selected="
emit('connectStarted', 'existing');
emitBinding($event);
"
@new-credential="onMenuAction('advanced')"
/>
<N8nButton
v-if="chooseExisting"
variant="ghost"
size="small"
@click="chooseExisting = false"
>{{ i18n.baseText('generic.cancel') }}</N8nButton
>
</template>
<N8nSetupConnection
v-else
:connected="connected"
:value-label="valueLabel"
:value="value"
:action-label="actionLabel"
:actions="actions"
:action-disabled="actionDisabled"
:loading="busy"
@action="connect"
@select="onMenuAction"
>
<N8nSegmentControl
v-if="gatewayAvailable"
:model-value="mode"
:options="modeOptions"
@update:model-value="
mode = $event;
modeChanged = true;
"
/>
<template v-if="useCredits">
<N8nText size="small" :class="$style.hint">{{
i18n.baseText('instanceAi.setupPanel.creditsDescription')
}}</N8nText>
</template>
<template #action-leading>
<N8nText v-if="useCredits && balanceLabel" step="xs">{{ balanceLabel }}</N8nText>
<N8nButton
v-else-if="!useCredits"
variant="ghost"
size="small"
:class="$style.help"
:disabled="helpDisabled || busy"
@click="askForHelp"
>
<N8nIcon icon="sparkles" size="small" />
{{ helpLabel }}
</N8nButton>
</template>
<template v-if="!useCredits && !canQuickConnect && !useAdvancedForm">
<label v-if="isOAuth && redirectUrl" :class="$style.redirect">
{{ i18n.baseText('instanceAi.setupPanel.redirectUrl') }}
<N8nCopyInput
:class="$style.redirectInput"
:value="redirectUrl"
:copy-label="i18n.baseText('instanceAi.setupPanel.copyRedirectUrl')"
size="small"
/>
</label>
<TemplatedAuthSimpleView
v-if="isTemplated"
compact
:credential-data="form.credentialData.value"
@update="onDataChange"
/>
<CredentialInputs
v-else
compact
:credential-type="item.credentialType"
:credential-properties="inlineFields"
:credential-data="form.credentialData.value"
:documentation-url="documentationUrl"
:show-validation-warnings="form.showValidationWarning.value"
@update="onDataChange"
/>
</template>
</N8nSetupConnection>
<N8nCallout v-if="form.authError.value" theme="danger">{{ form.authError.value }}</N8nCallout>
<N8nButton v-if="initializationFailed" variant="ghost" size="small" @click="initialize">
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
</template>
<style lang="scss" module>
.form {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.redirect {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
font-size: var(--font-size--xs);
color: var(--text-color--subtle);
}
.hint {
color: var(--text-color--subtle);
}
.help {
padding-inline: 0;
}
.form .existing {
margin-top: 0;
}
.redirectInput {
font-family: var(--font-family--monospace);
:deep(input) {
font-size: var(--font-size--2xs);
text-overflow: ellipsis;
}
}
</style>

View File

@ -1,31 +1,74 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import type { InstanceAiSetupItem } from '@n8n/api-types';
import {
computed,
onScopeDispose,
provide,
ref,
shallowReactive,
useTemplateRef,
watch,
} from 'vue';
import { useLocalStorage, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core';
import { useUsersStore } from '@n8n/stores/users.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
N8nSetupPanel,
N8nButton,
N8nIcon,
N8nText,
type SetupPanelItem,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import type { INodeParameters } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import {
LOCAL_STORAGE_INSTANCE_AI_SETUP_ITEMS,
LOCAL_STORAGE_INSTANCE_AI_SETUP_DISMISSED,
ResourceLocatorDropdownTeleportedKey,
} from '@/app/constants';
import { getWorkflow } from '@/app/api/workflows';
import { SETUP_PANEL_SUCCESS_DELAY } from '@/app/constants/durations';
import { deriveHomeProject } from '@/app/stores/workflowDocument.store';
import type { InstanceAiCredentialContext } from '@/app/composables/useInstanceAiEditorCapability';
import NodeIcon from '@/app/components/NodeIcon.vue';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { getAppNameFromCredType } from '@/app/utils/nodeTypesUtils';
import { getAppNameFromCredType, getAppNameFromNodeName } from '@/app/utils/nodeTypesUtils';
import { deriveServiceName } from '@/features/credentials/templatedAuth.utils';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useCredentialTestInBackground } from '@/features/credentials/composables/useCredentialTestInBackground';
import { useThread } from '../../instanceAi.store';
import { useSetupPanelState, type SetupPanelRow } from '../../composables/useSetupPanelState';
import { useSetupPanelState } from '../../composables/useSetupPanelState';
import { useSetupPanelExecution } from '../../composables/useSetupPanelExecution';
import {
buildInstanceAiArtifactCredentialQuestion,
buildInstanceAiCredentialHandoffContext,
} from '../../composables/useInstanceAiHandoff';
import { groupSetupPanelRows, type SetupPanelGroup } from '../../setupPanelGroups';
import { useSetupPanelTelemetry } from '../../composables/useSetupPanelTelemetry';
import {
useSetupPanelActions,
type SetupCredentialItem,
type SetupPanelApplyResult,
} from '../../composables/useSetupPanelActions';
import InstanceAiSetupPanelDetail from './InstanceAiSetupPanelDetail.vue';
import InstanceAiSetupCredential from './InstanceAiSetupCredential.vue';
import { AI_GATEWAY_MANAGED_TAG } from '../../constants';
import { applySetupParameterChanges } from '../../setupPanelParameterChanges';
const props = defineProps<{
/** The thread's active artifact workflow — the host gates rendering on it. */
/** Workflow selected from an artifact or an early setup announcement. */
workflowId: string;
projectId?: string;
}>();
const emit = defineEmits<{
'update:overlapHeight': [height: number];
}>();
// Resource menus must escape the scrollable card and the composer beneath it.
provide(ResourceLocatorDropdownTeleportedKey, true);
const i18n = useI18n();
const toast = useToast();
@ -33,15 +76,57 @@ const thread = useThread();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const rootStore = useRootStore();
const oauth = useCredentialOAuth();
const connectingItemId = ref<string>();
let active = true;
onScopeDispose(() => {
active = false;
oauth.cancelAuthorize();
});
const { testCredentialInBackground } = useCredentialTestInBackground();
const { rows, isAgentBuilding, getNodeByName, refreshWorkflow, workflowProjectId } =
useSetupPanelState({
thread,
workflowId: () => props.workflowId,
});
const {
rows,
rowSource,
credentialsAvailable,
isRefreshingWorkflow,
isAgentBuilding,
getNodeByName: getSavedNodeByName,
refreshWorkflow,
workflowProjectId,
isItemDone,
isCredentialConfigured,
} = useSetupPanelState({
thread,
workflowId: () => props.workflowId,
});
const credentialProjectId = computed(() => workflowProjectId.value ?? props.projectId);
const fetchedProject = ref<{ workflowId: string; projectId?: string }>();
const credentialProjectId = computed(
() =>
workflowProjectId.value ??
props.projectId ??
(fetchedProject.value?.workflowId === props.workflowId
? fetchedProject.value.projectId
: undefined),
);
// Early credential announcements can precede the artifact's project metadata.
watch(
() => (workflowProjectId.value || props.projectId ? undefined : props.workflowId),
async (id) => {
if (!id || fetchedProject.value?.workflowId === id) return;
try {
const workflow = await getWorkflow(rootStore.restApiContext, id);
if (props.workflowId === id)
fetchedProject.value = { workflowId: id, projectId: deriveHomeProject(workflow)?.id };
} catch {
// Saved-state derivation retries after the build ends.
}
},
{ immediate: true },
);
const isCredentialProjectReady = computed(() =>
projectsStore.myProjects.some((project) => project.id === credentialProjectId.value),
);
@ -56,73 +141,493 @@ watch(
);
const actions = useSetupPanelActions({
thread,
workflowId: () => props.workflowId,
isAgentBuilding,
onFlushResult: notifyApplyResult,
});
// --- Drill-down navigation (list ↔ detail swap inside the card) ---
function getNodeByName(name: string, includePendingParameters = true): INodeUi | undefined {
const node = getSavedNodeByName(name);
if (!node) return undefined;
let credentials = node.credentials;
for (const { item } of rows.value) {
if (
item.kind !== 'credential' ||
!item.nodeBindings?.some((binding) => binding.nodeName === name)
)
continue;
const pending = actions.getPendingCredential(item.id, name);
if (pending) credentials = { ...credentials, [item.credentialType]: pending };
}
const changes = includePendingParameters ? actions.getPendingParameterChanges(name) : [];
if (credentials === node.credentials && changes.length === 0) return node;
return {
...node,
credentials,
parameters: changes.length
? applySetupParameterChanges(node.parameters, changes)
: node.parameters,
};
}
// Show submitted values while writes wait. Execute still requires the saved rows to be complete.
const displayedRows = computed(() =>
rows.value.map((row) => {
if (row.item.kind === 'credential') {
const pending =
actions.getPendingCredential(row.item.id) ||
row.item.nodeBindings?.some(({ nodeName }) =>
actions.getPendingCredential(row.item.id, nodeName),
);
return pending ? { ...row, isDone: isItemDone(row.item, getNodeByName) } : row;
}
return actions.getPendingParameterChanges(row.item.nodeName).length
? { ...row, isDone: isItemDone(row.item, getNodeByName) }
: row;
}),
);
const execution = useSetupPanelExecution({ thread, workflowId: () => props.workflowId });
const selectedItemId = ref<string>();
const selectedRow = computed(() => rows.value.find((row) => row.item.id === selectedItemId.value));
// Persist dismissal, not completion. New requirements reopen the panel.
const setupDismissedKey = computed(() =>
LOCAL_STORAGE_INSTANCE_AI_SETUP_DISMISSED(
usersStore.currentUserId ?? '',
thread.id,
props.workflowId,
),
);
const setupDismissed = useLocalStorage(setupDismissedKey, false, { writeDefaults: false });
watch(
() => props.workflowId,
() => {
selectedItemId.value = undefined;
oauth.cancelAuthorize();
clearDetailState();
lastDetailId = undefined;
},
);
function itemNodeName(item: InstanceAiSetupItem): string | undefined {
return item.kind === 'parameters'
? item.nodeName
: item.nodeBindings?.find((binding) => getNodeByName(binding.nodeName))?.nodeName;
function nodeType(node: INodeUi) {
return nodeTypesStore.getNodeType(node.type, node.typeVersion);
}
/**
* The workflow node behind the selected item. Unresolvable while the row feed
* is event-only (agent mid-build, or the saved workflow still fetching) — the
* detail closes itself and rows stop offering drill-down until it resolves.
*/
const selectedNode = computed(() => {
const item = selectedRow.value?.item;
const nodeName = item ? itemNodeName(item) : undefined;
return nodeName ? getNodeByName(nodeName) : undefined;
// Remember visibility only. Completion still comes from the workflow and credentials.
const shownItemIds = useLocalStorage<string[]>(
() =>
LOCAL_STORAGE_INSTANCE_AI_SETUP_ITEMS(
usersStore.currentUserId ?? '',
thread.id,
props.workflowId,
),
[],
{ writeDefaults: false, flush: 'sync' },
);
const credentialsReady = computed(() => credentialsAvailable.value && !isRefreshingWorkflow.value);
watch(
[rows, credentialsReady],
([currentRows, ready]) => {
const added = currentRows.filter(
(row) =>
!row.isDone &&
(row.item.kind === 'parameters' || ready) &&
!shownItemIds.value.includes(row.item.id),
);
if (added.length)
shownItemIds.value = [...shownItemIds.value, ...added.map((row) => row.item.id)];
},
{ immediate: true, flush: 'sync' },
);
const groups = computed(() =>
groupSetupPanelRows(displayedRows.value, {
workflowId: props.workflowId,
getNodeByName,
getNodeType: nodeType,
}).filter((group) => {
const items = [...(group.credential ? [group.credential] : []), ...group.parameters];
return (
items.some((row) => shownItemIds.value.includes(row.item.id)) ||
items.some((row) => !row.isDone && (row.item.kind === 'parameters' || credentialsReady.value))
);
}),
);
const panelTelemetry = useSetupPanelTelemetry({
workflowId: () => props.workflowId,
thread,
rows,
groups,
shownItemIds,
ready: () =>
!setupDismissed.value &&
credentialsReady.value &&
(rowSource.value === 'derived' || rows.value.length > 0),
});
const selectedGroup = computed(() =>
groups.value.find((group) => group.id === selectedItemId.value),
);
const selectedNodes = computed(() =>
(selectedGroup.value?.credential?.item.nodeBindings ?? []).flatMap((binding) => {
const node = getNodeByName(binding.nodeName);
return node ? [node] : [];
}),
);
function canOpenRow(row: SetupPanelRow): boolean {
const nodeName = itemNodeName(row.item);
function groupById(id: string) {
return groups.value.find((group) => group.id === id);
}
function groupNodeType(id: string) {
const node = groupById(id)?.node;
return node ? nodeType(node) : null;
}
function groupName(group: SetupPanelGroup): string {
const item = group.credential?.item;
if (item)
return (
deriveServiceName(item.setupHint) ??
getAppNameFromNodeName(
getAppNameFromCredType(
item.appDisplayName ??
credentialsStore.getCredentialTypeByName(item.credentialType)?.displayName ??
item.credentialType,
),
)
);
return group.node
? getAppNameFromNodeName(nodeType(group.node)?.displayName ?? group.node.name)
: i18n.baseText('instanceAi.setupPanel.details');
}
const panelItems = computed<SetupPanelItem[]>(() =>
groups.value.map((group) => {
const pending: string[] = [];
if (group.credential && !group.credential.isDone) {
pending.push(i18n.baseText('instanceAi.setupPanel.connection'));
}
for (const row of group.parameters) {
if (row.isDone) continue;
const node = getNodeByName(row.item.nodeName);
const properties = node ? nodeType(node)?.properties : undefined;
for (const name of row.item.parameterNames) {
const root = name.split(/[.[\]]/)[0];
pending.push(properties?.find((property) => property.name === root)?.displayName ?? name);
}
}
return {
id: group.id,
title: groupName(group),
hasAction: Boolean(
group.credential &&
!group.credential.isDone &&
oauth.isOAuthCredentialType(group.credential.item.credentialType) &&
oauth.canOAuthCredentialQuickConnect(group.credential.item.credentialType) &&
credentialsStore.getUsableCredentialByType(group.credential.item.credentialType)
.length === 0,
),
completed:
(!group.credential || group.credential.isDone) &&
group.parameters.every((row) => row.isDone),
subtitle: pending.length
? i18n.baseText('instanceAi.setupPanel.needs', {
interpolate: { items: [...new Set(pending)].join(', ') },
})
: undefined,
disabled: group.credential
? !isCredentialProjectReady.value ||
!credentialsAvailable.value ||
!credentialsStore.getCredentialTypeByName(group.credential.item.credentialType)
: !group.parameters.some((row) => getNodeByName(row.item.nodeName)),
};
}),
);
watch(
() =>
selectedItemId.value &&
panelItems.value.find((item) => item.id === selectedItemId.value && !item.disabled),
(item) => {
if (selectedItemId.value && !item) {
selectedItemId.value = undefined;
clearDetailState();
}
},
);
const parameterEditors = computed(() =>
(selectedGroup.value?.parameters ?? []).flatMap((row) => {
// Keep saved parameters as the baseline, so a failed write leaves the input editable.
const node = getNodeByName(row.item.nodeName, false);
return node
? [
{
item: row.item,
node,
pendingChanges: actions.getPendingParameterChanges(row.item.nodeName),
},
]
: [];
}),
);
const perNodeGroups = shallowReactive(new Set<string>());
const perNodeCredentials = computed(() => {
const group = selectedGroup.value;
if (!group?.credential || selectedNodes.value.length < 2) return false;
if (perNodeGroups.has(group.id)) return true;
const type = group.credential.item.credentialType;
return (
nodeName !== undefined &&
getNodeByName(nodeName) !== undefined &&
(row.item.kind !== 'credential' || isCredentialProjectReady.value)
new Set(
selectedNodes.value.map((node) => {
const credential = node.credentials?.[type];
return credential?.__aiGatewayManaged ? AI_GATEWAY_MANAGED_TAG : credential?.id;
}),
).size > 1
);
}
function openRow(row: SetupPanelRow) {
if (canOpenRow(row)) selectedItemId.value = row.item.id;
}
function closeDetail() {
selectedItemId.value = undefined;
}
// --- Row rendering ---
function rowName(item: InstanceAiSetupItem): string {
if (item.kind === 'parameters') return item.nodeName;
return getAppNameFromCredType(
item.appDisplayName ??
credentialsStore.getCredentialTypeByName(item.credentialType)?.displayName ??
item.credentialType,
);
}
function rowNodeType(item: InstanceAiSetupItem) {
const nodeName = itemNodeName(item);
const node = nodeName ? getNodeByName(nodeName) : undefined;
return node ? nodeTypesStore.getNodeType(node.type, node.typeVersion) : null;
}
});
const detailSections = computed(() => {
const group = selectedGroup.value;
if (!group) return [];
const credential = group.credential?.item;
const editors = parameterEditors.value;
const nodes = credential ? selectedNodes.value : editors.map((editor) => editor.node);
return [
...(credential && !perNodeCredentials.value
? [
{
id: group.id,
title: undefined,
credential,
nodes: selectedNodes.value,
editors: [],
showParameters: false,
},
]
: []),
...nodes
.filter(
(node) =>
perNodeCredentials.value || editors.some((editor) => editor.node.name === node.name),
)
.map((node) => ({
id: `node:${node.name}`,
title: nodes.length > 1 ? node.name : undefined,
credential:
credential && perNodeCredentials.value
? {
...credential,
id: `${credential.id}:${node.name}`,
nodeBindings: [{ nodeName: node.name }],
}
: undefined,
nodes: [node],
editors: editors.filter((editor) => editor.node.name === node.name),
showParameters:
credential && perNodeCredentials.value
? isCredentialConfigured(node.credentials?.[credential.credentialType])
: !group.credential || group.credential.isDone,
})),
];
});
const parameterEditorComponents = useTemplateRef<
Array<InstanceType<typeof InstanceAiSetupPanelDetail>>
>('parameterEditorComponents');
const showParameterConfirm = computed(() =>
detailSections.value.some((section) => section.showParameters && section.editors.length > 0),
);
// --- Apply paths (T6 actions; row done-ness re-derives after each write) ---
const isApplying = ref(false);
const busyCredentials = shallowReactive(new Set<string>());
const dirtyCredentials = shallowReactive(new Set<string>());
const credentialBusy = computed(() => busyCredentials.size > 0);
const credentialHasChanges = computed(() => dirtyCredentials.size > 0);
const dirtyParameters = shallowReactive(new Set<string>());
const requestingExecution = execution.isRunning;
const isChatBusy = computed(
() => thread.isStreaming || thread.isSendingMessage || thread.isAwaitingConfirmation,
);
async function notifyApplyResult(result: SetupPanelApplyResult) {
async function onAskForHelp(credential: InstanceAiCredentialContext) {
if (isChatBusy.value) return;
await thread.sendMessage(buildInstanceAiArtifactCredentialQuestion(credential), {
authorship: { kind: 'prefill', prefillType: 'handoff_credential_setup' },
pushRef: rootStore.pushRef,
handoffContext: buildInstanceAiCredentialHandoffContext(credential),
});
}
const allRowsDone = computed(() => rows.value.length > 0 && rows.value.every((row) => row.isDone));
const hasChanges = computed(() => credentialHasChanges.value || dirtyParameters.size > 0);
const activeGroupComplete = computed(() => {
const group = selectedGroup.value;
return Boolean(
group &&
(!group.credential || group.credential.isDone) &&
group.parameters.every((row) => row.isDone),
);
});
const canAutoClose = ref(false);
const reducedMotion = usePreferredReducedMotion();
let selectionVersion = 0;
let lastDetailId: string | undefined;
const readyToReturn = computed(
() =>
canAutoClose.value &&
activeGroupComplete.value &&
!hasChanges.value &&
!isApplying.value &&
!actions.isApplying.value &&
!credentialBusy.value &&
!isRefreshingWorkflow.value &&
!connectingItemId.value,
);
const { start: scheduleReturn, stop: cancelReturn } = useTimeoutFn(
() => {
if (readyToReturn.value) selectedItemId.value = undefined;
},
computed(() => (reducedMotion.value === 'reduce' ? 0 : SETUP_PANEL_SUCCESS_DELAY)),
{ immediate: false },
);
function clearDetailState() {
busyCredentials.clear();
dirtyCredentials.clear();
dirtyParameters.clear();
}
function onDetailClosed() {
if (selectedItemId.value) return;
clearDetailState();
lastDetailId = undefined;
}
watch(
selectedItemId,
(id) => {
selectionVersion++;
cancelReturn();
canAutoClose.value = Boolean(id && !activeGroupComplete.value);
if (id && id !== lastDetailId) {
clearDetailState();
lastDetailId = id;
}
},
{ flush: 'sync' },
);
watch(readyToReturn, (ready) => {
cancelReturn();
if (ready) scheduleReturn();
});
function finishSubmission(
result: SetupPanelApplyResult,
version: number | undefined,
workflowId: string,
) {
if (
active &&
props.workflowId === workflowId &&
version === selectionVersion &&
(result === 'applied' || result === 'noop' || result === 'queued')
)
canAutoClose.value = true;
}
const terminalStatus = computed(() => {
if (!allRowsDone.value || hasChanges.value) return 'incomplete';
if (requestingExecution.value) return 'executing';
if (isAgentBuilding.value) return 'incomplete';
if (
rowSource.value !== 'derived' ||
!credentialsReady.value ||
isApplying.value ||
actions.isApplying.value ||
actions.pendingApplyCount.value > 0 ||
credentialBusy.value ||
connectingItemId.value
)
return 'validating';
return 'complete';
});
watch(
[
() => rows.value.some((row) => !row.isDone),
hasChanges,
rowSource,
credentialsReady,
isAgentBuilding,
],
([pending, dirty, source, ready, building]) => {
if (dirty || (pending && ((source === 'derived' && ready) || building)))
setupDismissed.value = false;
},
{ immediate: true },
);
async function onExecute() {
if (terminalStatus.value !== 'complete' || isChatBusy.value || requestingExecution.value) return;
const workflowId = props.workflowId;
const dismissedKey = setupDismissedKey.value;
try {
const result = await execution.executeWorkflow();
if (!active || !result?.notified) return;
if (props.workflowId !== workflowId) {
// On return, the pending-row watcher reopens setup if this workflow needs more input.
localStorage.setItem(dismissedKey, 'true');
return;
}
if (
allRowsDone.value &&
!isAgentBuilding.value &&
!hasChanges.value &&
!actions.isApplying.value &&
actions.pendingApplyCount.value === 0
) {
setupDismissed.value = true;
panelTelemetry.trackDismissed('execution_finished');
}
} catch (error) {
toast.showError(error, i18n.baseText('instanceAi.setupPanel.executeError'));
}
}
async function connectFromRow(id: string) {
const group = groupById(id);
const item = group?.credential?.item;
const projectId = credentialProjectId.value;
if (!group || !item || !projectId || connectingItemId.value) return;
const workflowId = props.workflowId;
const nodeName = item.nodeBindings?.find((binding) => getNodeByName(binding.nodeName))?.nodeName;
const node = nodeName ? getNodeByName(nodeName) : undefined;
const bind = async (credentialId: string) => {
if (!active || props.workflowId !== workflowId) return;
await onBindCredential(item, credentialId);
if (
active &&
props.workflowId === workflowId &&
groupById(id)?.parameters.some((row) => !row.isDone)
)
selectedItemId.value = id;
};
panelTelemetry.trackConnectionStarted(item, 'oauth');
connectingItemId.value = id;
try {
const credential = await oauth.createAndAuthorize(item.credentialType, node?.type, {
projectId,
workflowId,
});
if (credential) await bind(credential.id);
} catch (error) {
if (active) toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
} finally {
connectingItemId.value = undefined;
}
}
async function notifyApplyResult(result: SetupPanelApplyResult, workflowId: string) {
if (!active || props.workflowId !== workflowId) return;
if (result === 'error' || result === 'conflict') {
toast.showMessage({ title: i18n.baseText('instanceAi.setupPanel.applyError'), type: 'error' });
}
@ -132,22 +637,49 @@ async function notifyApplyResult(result: SetupPanelApplyResult) {
}
async function onBindCredential(item: SetupCredentialItem, credentialId: string) {
const credential = credentialsStore.getCredentialById(credentialId);
const workflowId = props.workflowId;
const version =
selectedGroup.value?.credential?.item.credentialType === item.credentialType
? selectionVersion
: undefined;
if (credentialId === AI_GATEWAY_MANAGED_TAG) {
const result = await actions.bindCredential(item, {
id: null,
name: '',
__aiGatewayManaged: true,
});
await notifyApplyResult(result, workflowId);
panelTelemetry.trackConnectionCompleted(item, null, result);
finishSubmission(result, version, workflowId);
return;
}
const credential =
(credentialsStore.hasUsableCredentialsForScope({ workflowId })
? credentialsStore.getUsableCredentialById(credentialId)
: undefined) ?? credentialsStore.getCredentialById(credentialId);
if (!credential) return;
void testCredentialInBackground(credential.id, credential.name, item.credentialType);
await notifyApplyResult(
await actions.bindCredential(item, { id: credential.id, name: credential.name }),
);
const result = await actions.bindCredential(item, { id: credential.id, name: credential.name });
await notifyApplyResult(result, workflowId);
panelTelemetry.trackConnectionCompleted(item, credential.id, result);
finishSubmission(result, version, workflowId);
}
async function onApplyParameters(
nodeName: string,
values: INodeParameters,
baseline?: INodeParameters,
) {
async function onConfirmParameters() {
if (isApplying.value || credentialBusy.value) return;
const editors = parameterEditorComponents.value ?? [];
const submissions = editors.flatMap((editor) => {
const submission = editor.getSubmission();
return submission ? [submission] : [];
});
if (!submissions.length) return;
const workflowId = props.workflowId;
const version = selectionVersion;
isApplying.value = true;
try {
await notifyApplyResult(await actions.applyParameterValues(nodeName, values, baseline));
const result = await actions.applyParameterBatch(submissions);
await notifyApplyResult(result, workflowId);
finishSubmission(result, version, workflowId);
} finally {
isApplying.value = false;
}
@ -155,141 +687,134 @@ async function onApplyParameters(
</script>
<template>
<section v-if="rows.length > 0" :class="$style.panel" data-test-id="instance-ai-setup-panel">
<template v-if="selectedRow && selectedNode && canOpenRow(selectedRow)">
<header :class="$style.detailHeader">
<N8nButton
variant="ghost"
size="small"
icon-only
:aria-label="i18n.baseText('generic.back')"
data-test-id="instance-ai-setup-panel-back"
@click="closeDetail"
>
<N8nIcon icon="chevron-left" size="small" />
</N8nButton>
<N8nText size="medium" color="text-dark" bold>{{ rowName(selectedRow.item) }}</N8nText>
</header>
<InstanceAiSetupPanelDetail
:key="selectedRow.item.id"
:item="selectedRow.item"
:node="selectedNode"
:workflow-id="workflowId"
:project-id="credentialProjectId"
:is-applying="isApplying"
@bind-credential="onBindCredential"
@apply-parameters="onApplyParameters"
/>
<N8nSetupPanel
v-if="!setupDismissed"
:key="workflowId"
v-model:active-item-id="selectedItemId"
:items="panelItems"
:status="terminalStatus"
:execute-disabled="isChatBusy || requestingExecution"
data-test-id="instance-ai-setup-panel"
@execute="onExecute"
@detail-closed="onDetailClosed"
@update:overlap-height="emit('update:overlapHeight', $event)"
>
<template #action="{ item }">
<N8nButton
size="small"
variant="subtle"
:disabled="item.disabled || Boolean(connectingItemId)"
:loading="connectingItemId === item.id"
@click="connectFromRow(item.id)"
>
{{ i18n.baseText('instanceAi.setupPanel.connect') }}
</N8nButton>
</template>
<ul v-else :class="$style.rows">
<li v-for="row in rows" :key="row.item.id">
<button
type="button"
:class="$style.row"
:disabled="!canOpenRow(row)"
data-test-id="instance-ai-setup-panel-row"
@click="openRow(row)"
<template #icon="{ item }">
<CredentialIcon
v-if="groupById(item.id)?.credential"
:credential-type-name="groupById(item.id)?.credential?.item.credentialType ?? ''"
:size="16"
/>
<NodeIcon
v-else-if="groupById(item.id)?.node"
:node-type="groupNodeType(item.id)"
:size="16"
/>
<N8nIcon v-else icon="sliders-horizontal" size="small" />
</template>
<template #detail="{ item }">
<div v-if="selectedGroup" :class="$style.detail">
<section
v-for="section in detailSections"
:key="section.id"
:class="[$style.section, { [$style.divided]: section.title }]"
>
<CredentialIcon
v-if="row.item.kind === 'credential'"
:credential-type-name="row.item.credentialType"
:size="16"
<N8nText v-if="section.title" tag="h3" size="medium" bold :class="$style.sectionTitle">{{
section.title
}}</N8nText>
<InstanceAiSetupCredential
v-if="section.credential && credentialProjectId"
:key="section.credential.id"
:item="section.credential"
:node="section.nodes[0]"
:pending-credential="actions.getPendingCredential(section.credential.id)"
:nodes="section.nodes"
:workflow-id="workflowId"
:project-id="credentialProjectId"
:help-disabled="isChatBusy"
:allow-per-node="!perNodeCredentials && selectedNodes.length > 1"
@set-credentials-per-node="perNodeGroups.add(item.id)"
@ask-for-help="onAskForHelp"
@bind-credential="
(credential, id) => selectedItemId === item.id && onBindCredential(credential, id)
"
@update:busy="
(selectedItemId === item.id || (!selectedItemId && !$event)) &&
($event ? busyCredentials.add(section.id) : busyCredentials.delete(section.id))
"
@update:has-changes="
(selectedItemId === item.id || (!selectedItemId && !$event)) &&
($event ? dirtyCredentials.add(section.id) : dirtyCredentials.delete(section.id))
"
@connect-started="
selectedItemId === item.id &&
panelTelemetry.trackConnectionStarted(section.credential, $event)
"
/>
<NodeIcon v-else :node-type="rowNodeType(row.item)" :size="16" />
<N8nText :class="$style.rowName" size="medium" color="text-dark">
{{ rowName(row.item) }}
</N8nText>
<N8nText
v-if="row.isDone"
:class="$style.rowStatus"
<template v-if="section.showParameters">
<div v-for="editor in section.editors" :key="editor.item.id">
<InstanceAiSetupPanelDetail
ref="parameterEditorComponents"
:item="editor.item"
:node="editor.node"
:workflow-id="workflowId"
:project-id="credentialProjectId"
:pending-changes="editor.pendingChanges"
@update:has-changes="
(selectedItemId === item.id || (!selectedItemId && !$event)) &&
($event
? dirtyParameters.add(editor.item.id)
: dirtyParameters.delete(editor.item.id))
"
/>
</div>
</template>
</section>
<div v-if="showParameterConfirm" :class="$style.footer">
<N8nButton
size="small"
color="success"
data-test-id="instance-ai-setup-panel-row-done"
:disabled="dirtyParameters.size === 0 || isApplying || credentialBusy"
:loading="isApplying"
data-test-id="instance-ai-setup-panel-confirm"
@click="onConfirmParameters"
>
<N8nIcon icon="check" size="small" />
{{
i18n.baseText(
row.item.kind === 'credential'
? 'instanceAi.setupPanel.connected'
: 'instanceAi.setupPanel.ready',
)
}}
</N8nText>
<N8nIcon
v-if="canOpenRow(row)"
icon="chevron-right"
size="small"
:class="$style.rowChevron"
/>
</button>
</li>
</ul>
</section>
{{ i18n.baseText(activeGroupComplete ? 'generic.update' : 'generic.confirm') }}
</N8nButton>
</div>
</div>
</template>
</N8nSetupPanel>
</template>
<style lang="scss" module>
.panel {
.detail,
.section {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
padding: var(--spacing--2xs);
border: var(--border);
border-radius: var(--radius--lg);
background-color: var(--background--surface);
gap: var(--spacing--xs);
}
.section + .divided {
border-top: var(--border);
padding-top: var(--spacing--sm);
}
.rows {
display: flex;
flex-direction: column;
.sectionTitle {
margin: 0;
padding: 0;
list-style: none;
}
.row {
.footer {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
padding: var(--spacing--2xs);
border: 0;
border-radius: var(--radius);
background: none;
cursor: pointer;
text-align: left;
&:hover:not(:disabled) {
background-color: var(--background--hover);
}
&:disabled {
cursor: default;
}
}
.rowName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowStatus {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
white-space: nowrap;
}
.rowChevron {
color: var(--icon-color);
}
.detailHeader {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
justify-content: flex-end;
}
</style>

View File

@ -1,133 +1,63 @@
<script lang="ts" setup>
import { computed, onScopeDispose, provide, ref, watch } from 'vue';
import isEqual from 'lodash/isEqual';
import { N8nButton, N8nText, N8nTooltip } from '@n8n/design-system';
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE, type InstanceAiSetupItem } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import type { InstanceAiSetupItem } from '@n8n/api-types';
import { findPlaceholderDetails } from '@n8n/utils/placeholder';
import { deepCopy, type INodeParameters, type INodeProperties } from 'n8n-workflow';
import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue';
import { deepCopy, NodeHelpers, type INodeParameters, type INodeProperties } from 'n8n-workflow';
import ParameterInputList from '@/features/ndv/parameters/components/ParameterInputList.vue';
import { deriveServiceName } from '@/features/credentials/templatedAuth.utils';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { ExpressionLocalResolveContextSymbol, WorkflowDocumentStoreKey } from '@/app/constants';
import {
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { disposeNDVStore, useNDVStore } from '@/features/ndv/shared/ndv.store';
import { setParameterValue as setParameterValueByPath } from '@/app/utils/parameterUtils';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import type { INodeUi, INodeUpdatePropertiesInformation, IUpdateInformation } from '@/Interface';
import type { SetupCredentialItem } from '../../composables/useSetupPanelActions';
import { AI_GATEWAY_MANAGED_TAG } from '../../constants';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { CompactParameterHintsKey } from '@/app/constants/injectionKeys';
import { useSetupPanelDocument } from '../../composables/useSetupPanelDocument';
import type { SetupParameterSubmission } from '../../composables/useSetupPanelActions';
import {
applySetupParameterChanges,
getSetupParameterChanges,
getSetupParameterValue,
mergeSetupParameterChanges,
type SetupParameterChange,
} from '../../setupPanelParameterChanges';
const props = defineProps<{
item: InstanceAiSetupItem;
/** The workflow node behind the item — the panel only opens a detail when it resolves. */
item: Extract<InstanceAiSetupItem, { kind: 'parameters' }>;
/** The workflow node that provides these parameters. */
node: INodeUi;
workflowId?: string;
projectId?: string;
isApplying?: boolean;
/** Confirmed edits waiting for the workflow write. */
pendingChanges?: SetupParameterChange[];
}>();
const emit = defineEmits<{
bindCredential: [item: SetupCredentialItem, credentialId: string];
applyParameters: [nodeName: string, values: INodeParameters, baseline: INodeParameters];
'update:hasChanges': [value: boolean];
}>();
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
provide(CompactParameterHintsKey, true);
const credentialItem = computed(() => (props.item.kind === 'credential' ? props.item : undefined));
const parametersItem = computed(() => (props.item.kind === 'parameters' ? props.item : undefined));
// --- Credential selection (local until the bind PATCH re-derives the rows) ---
function savedCredentialId(): string | null {
const item = props.item;
if (item.kind !== 'credential') return null;
const assigned = props.node.credentials?.[item.credentialType];
if (!assigned || typeof assigned === 'string') return null;
if (assigned.__aiGatewayManaged === true) return AI_GATEWAY_MANAGED_TAG;
return assigned.id ?? null;
}
const selectedCredentialId = ref<string | null>(savedCredentialId());
watch(savedCredentialId, (id) => {
selectedCredentialId.value = id;
});
const selectedCredentials = computed<INodeUi['credentials']>(() => {
const item = credentialItem.value;
if (!item) return undefined;
if (selectedCredentialId.value === AI_GATEWAY_MANAGED_TAG) {
return { [item.credentialType]: { id: null, name: '', __aiGatewayManaged: true } };
}
const cred = selectedCredentialId.value
? credentialsStore.getCredentialById(selectedCredentialId.value)
: undefined;
if (cred) return { [item.credentialType]: { id: cred.id, name: cred.name } };
return selectedCredentialId.value === savedCredentialId() ? props.node.credentials : {};
});
function onCredentialSelected(update: INodeUpdatePropertiesInformation) {
const item = credentialItem.value;
if (!item) return;
const data = update.properties.credentials?.[item.credentialType];
let credId: string | null = null;
if (data && typeof data !== 'string') {
credId = data.__aiGatewayManaged === true ? AI_GATEWAY_MANAGED_TAG : (data.id ?? null);
}
selectedCredentialId.value = credId;
// T8 will persist managed credentials. The tag is not a credential ID.
if (credId && credId !== AI_GATEWAY_MANAGED_TAG) {
emit('bindCredential', item, credId);
}
}
// Templated Custom Auth is one type shared by every service: name the selector
// after the service from the recipe, and never auto-select another service's key.
const isTemplatedType = computed(
() => credentialItem.value?.credentialType === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
);
const credentialsFieldLabel = computed(() => {
if (!isTemplatedType.value) return undefined;
const name = deriveServiceName(credentialItem.value?.setupHint);
return name
? i18n.baseText('instanceAi.credential.fieldLabel', { interpolate: { name } })
: undefined;
});
const boundNodeNames = computed(() =>
(credentialItem.value?.nodeBindings ?? []).map((binding) => binding.nodeName),
);
const usedByNodesLabel = computed(() =>
i18n.baseText('instanceAi.workflowSetup.usedByNodes', {
adjustToNumber: boundNodeNames.value.length,
interpolate: { count: boundNodeNames.value.length },
}),
);
const parametersItem = computed(() => props.item);
// --- Parameter edits (buffered locally, applied on Confirm) ---
const parameterChanges = ref<SetupParameterChange[]>([]);
const parameterChanges = ref<SetupParameterChange[]>([...(props.pendingChanges ?? [])]);
const touchedParameters = ref(new Set<string>());
onScopeDispose(() => emit('update:hasChanges', false));
const displayParameters = computed(() =>
applySetupParameterChanges(props.node.parameters, parameterChanges.value),
applySetupParameterChanges(
props.node.parameters,
mergeSetupParameterChanges(props.pendingChanges ?? [], parameterChanges.value),
),
);
const hasChanges = computed(
() =>
!isEqual(
applySetupParameterChanges(props.node.parameters, props.pendingChanges ?? []),
displayParameters.value,
),
);
// Submitted queue entries are confirmed; only newer edits remain a draft.
watch(hasChanges, (value) => emit('update:hasChanges', value));
const parameterRoots = computed(
() =>
new Set([
@ -156,21 +86,31 @@ function onParameterValueChanged(update: IUpdateInformation) {
if (!parameterRoots.value.has(parameterName.split(/[.[\]]/)[0])) return;
const next = deepCopy(displayParameters.value);
setParameterValueByPath(next, parameterName, update.value);
parameterChanges.value = getSetupParameterChanges(props.node.parameters, next);
parameterChanges.value = mergeSetupParameterChanges(
parameterChanges.value,
getSetupParameterChanges(displayParameters.value, next),
);
}
function onConfirm() {
function getSubmission(): SetupParameterSubmission | undefined {
for (const parameter of parameterDefinitions.value) touchedParameters.value.add(parameter.name);
const item = parametersItem.value;
if (!item || !parameterChanges.value.length) return;
if (!item || !hasChanges.value) return;
// Pass the baseline so queued writes preserve later edits to sibling fields.
const values: INodeParameters = {};
for (const change of parameterChanges.value) {
const root = change.path[0];
if (typeof root === 'string') values[root] = displayParameters.value[root];
}
emit('applyParameters', item.nodeName, values, props.node.parameters);
return {
nodeName: item.nodeName,
values,
baseline: applySetupParameterChanges(props.node.parameters, props.pendingChanges ?? []),
};
}
defineExpose({ getSubmission });
const nodeType = computed(() =>
nodeTypesStore.getNodeType(props.node.type, props.node.typeVersion),
);
@ -181,6 +121,12 @@ const parameterDefinitions = computed<INodeProperties[]>(() => {
return nodeType.value.properties.filter((property) => parameterRoots.value.has(property.name));
});
const hiddenIssuesInputs = computed(() =>
parameterDefinitions.value
.filter((parameter) => !touchedParameters.value.has(parameter.name))
.map((parameter) => parameter.name),
);
const assignmentCollectionEditableValueIndices = computed<Record<string, number[]>>(() => {
const result: Record<string, number[]> = {};
// ParameterInputList can retain a control briefly after its definition changes.
@ -221,80 +167,30 @@ const assignmentCollectionEditableValueIndices = computed<Record<string, number[
return result;
});
// --- Per-item document/NDV store scaffolding ---
// NodeCredentials and ParameterInputList descendants materialize an NDV store
// via injectNDVStore(), keyed by the provided document id. Pinia stores are not
// freed on unmount, so dispose the previous id when it changes and the final
// one on scope teardown (same pattern as WorkflowSetupSectionBody).
const displayNode = computed<INodeUi>(() => {
if (credentialItem.value) {
return { ...props.node, credentials: selectedCredentials.value };
}
return { ...props.node, parameters: displayParameters.value };
});
const documentId = computed(() =>
createWorkflowDocumentId(props.workflowId ?? 'instance-ai-setup-panel', props.item.id),
);
const workflowDocumentStore = computed(() => useWorkflowDocumentStore(documentId.value));
watch(
displayNode,
(node) => {
workflowDocumentStore.value.setNodes([node]);
},
{ immediate: true, deep: true },
);
function disposeStores(id: WorkflowDocumentId) {
disposeNDVStore(useNDVStore(id));
disposeWorkflowDocumentStore(useWorkflowDocumentStore(id));
}
watch(documentId, (_newId, oldId) => {
if (oldId) disposeStores(oldId);
});
onScopeDispose(() => {
disposeStores(documentId.value);
});
const expressionContext = computed<ExpressionLocalResolveContext | undefined>(() => ({
localResolve: true,
nodeName: displayNode.value.name,
additionalKeys: {},
const displayNode = computed<INodeUi>(() => ({
...props.node,
parameters: nodeType.value
? (NodeHelpers.getNodeParameters(
nodeType.value.properties,
displayParameters.value,
true,
true,
props.node,
nodeType.value,
) ?? displayParameters.value)
: displayParameters.value,
}));
provide(ExpressionLocalResolveContextSymbol, expressionContext);
provide(WorkflowDocumentStoreKey, workflowDocumentStore);
useSetupPanelDocument({
workflowId: () => props.workflowId,
itemId: () => props.item.id,
node: displayNode,
});
</script>
<template>
<div :class="$style.body" data-test-id="instance-ai-setup-panel-detail">
<NodeCredentials
v-if="credentialItem"
:node="displayNode"
:override-cred-type="credentialItem.credentialType"
:project-id="projectId"
:workflow-id="workflowId"
standalone
hide-issues
skip-auto-select
:credential-setup-hint="credentialItem.setupHint"
:credentials-field-label="credentialsFieldLabel"
@credential-selected="onCredentialSelected"
>
<template v-if="boundNodeNames.length > 1" #label-postfix>
<N8nTooltip placement="top">
<template #content>{{ boundNodeNames.join(', ') }}</template>
<N8nText size="small" color="text-light">{{ usedByNodesLabel }}</N8nText>
</N8nTooltip>
</template>
</NodeCredentials>
<template v-else-if="parametersItem">
<template v-if="parametersItem">
<ParameterInputList
:parameters="parameterDefinitions"
:node-values="{ parameters: displayNode.parameters }"
@ -304,20 +200,11 @@ provide(WorkflowDocumentStoreKey, workflowDocumentStore);
:remove-first-parameter-margin="true"
:remove-last-parameter-margin="true"
:options-overrides="{ hideExpressionSelector: true, hideFocusPanelButton: true }"
:hidden-issues-inputs="hiddenIssuesInputs"
:assignment-collection-editable-value-indices="assignmentCollectionEditableValueIndices"
@value-changed="onParameterValueChanged"
@parameter-blur="touchedParameters.add($event)"
/>
<div :class="$style.footer">
<N8nButton
size="small"
:disabled="parameterChanges.length === 0 || isApplying"
:loading="isApplying"
data-test-id="instance-ai-setup-panel-confirm"
@click="onConfirm"
>
{{ i18n.baseText('generic.confirm') }}
</N8nButton>
</div>
</template>
</div>
</template>
@ -327,14 +214,5 @@ provide(WorkflowDocumentStoreKey, workflowDocumentStore);
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
:global(.node-credentials) {
margin-top: 0;
}
}
.footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,843 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { computed, defineComponent, h, ref, type PropType } from 'vue';
import { flushPromises } from '@vue/test-utils';
import { fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { mock } from 'vitest-mock-extended';
import { getResourcePermissions } from '@n8n/permissions';
import { useRootStore } from '@n8n/stores/useRootStore';
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '@n8n/api-types';
import {
DOMAIN_RESTRICTION_FIELDS,
type ICredentialDataDecryptedObject,
type INodeProperties,
} from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestNode } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useUIStore } from '@/app/stores/ui.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import type { IUpdateInformation } from '@/Interface';
import { AI_GATEWAY_MANAGED_TAG } from '../../../constants';
import InstanceAiSetupCredential from '../InstanceAiSetupCredential.vue';
const mocks = vi.hoisted(() => ({
formOptions: vi.fn(),
isOAuth: vi.fn(),
canQuickConnect: vi.fn(),
authorize: vi.fn(),
cancelAuthorize: vi.fn(),
quickOption: vi.fn(),
quickConnect: vi.fn(),
cancelConnect: vi.fn(),
showError: vi.fn(),
track: vi.fn(),
runHook: vi.fn().mockResolvedValue(undefined),
openTopUp: vi.fn(),
}));
vi.mock('../../../composables/useSetupPanelDocument', () => ({ useSetupPanelDocument: vi.fn() }));
vi.mock('@/features/credentials/composables/useCredentialForm', () => ({
useCredentialForm: (options: unknown) => {
mocks.formOptions(options);
return form;
},
}));
vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({
useCredentialOAuth: () => ({
isOAuthCredentialType: mocks.isOAuth,
canOAuthCredentialQuickConnect: mocks.canQuickConnect,
createAndAuthorize: mocks.authorize,
cancelAuthorize: mocks.cancelAuthorize,
}),
}));
vi.mock('@/features/credentials/quickConnect/composables/useQuickConnect', () => ({
useQuickConnect: () => ({
getQuickConnectOption: mocks.quickOption,
connect: mocks.quickConnect,
cancelConnect: mocks.cancelConnect,
}),
}));
vi.mock('@/app/composables/useAiGateway', () => ({ useAiGateway: () => gateway }));
vi.mock('@/app/composables/useAiGatewayTopUp', () => ({
useAiGatewayTopUp: () => ({ openTopUp: mocks.openTopUp }),
}));
vi.mock('@/app/composables/useExternalHooks', () => ({
useExternalHooks: () => ({ run: mocks.runHook }),
}));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError: mocks.showError }) }));
vi.mock('@n8n/composables/useTelemetry', () => ({ useTelemetry: () => ({ track: mocks.track }) }));
function createForm() {
const credentialData = ref<ICredentialDataDecryptedObject>({
apiKey: '',
homeProject: 'ui-only',
});
return {
credentialData,
credentialName: ref('Service account'),
credentialPermissions: ref(getResourcePermissions(['credential:create']).credential),
credentialType: ref({
name: 'serviceApi',
displayName: 'Service API',
documentationUrl: 'service',
}),
credentialProperties: ref<INodeProperties[]>([
{ name: 'apiKey', displayName: 'API key', type: 'string', default: '', required: true },
{ name: 'optional', displayName: 'Optional', type: 'string', default: '' },
{ name: 'hidden', displayName: 'Hidden', type: 'hidden', default: '', required: true },
]),
parentTypes: ref<string[]>([]),
isOAuthType: computed(
() => mocks.isOAuth() && credentialData.value.grantType !== 'clientCredentials',
),
setCredentialPropertyDefaults: vi.fn(),
requiredPropertiesFilled: computed(() => Boolean(credentialData.value.apiKey)),
showValidationWarning: ref(false),
isCredentialTestable: ref(true),
testedSuccessfully: ref(true),
authError: ref(''),
initialize: vi.fn().mockResolvedValue(undefined),
testCredential: vi.fn().mockResolvedValue(undefined),
onDataChange: vi.fn((update: IUpdateInformation) => {
if (typeof update.value !== 'string') return false;
credentialData.value = { ...credentialData.value, [update.name]: update.value };
return true;
}),
};
}
let form: ReturnType<typeof createForm>;
let gateway: {
isEnabled: ReturnType<typeof ref<boolean>>;
balance: ReturnType<typeof ref<number>>;
isCredentialTypeSupported: ReturnType<typeof vi.fn>;
isNodeTypeVersionSupported: ReturnType<typeof vi.fn>;
isActionSupported: ReturnType<typeof vi.fn>;
fetchConfig: ReturnType<typeof vi.fn>;
fetchWallet: ReturnType<typeof vi.fn>;
};
const InputStub = defineComponent({
props: {
credentialProperties: Array as PropType<INodeProperties[]>,
credentialData: { type: Object as PropType<ICredentialDataDecryptedObject>, required: true },
},
emits: ['update'],
setup(props, { emit }) {
return () =>
h('div', [
h('input', {
'aria-label': 'API key',
value: props.credentialData.apiKey,
onInput: (event: Event) =>
emit('update', { name: 'apiKey', value: (event.target as HTMLInputElement).value }),
}),
h('output', { 'data-test-id': 'fields' }, JSON.stringify(props.credentialProperties)),
]);
},
});
const ExistingCredentialStub = defineComponent({
props: ['workflowId', 'projectId', 'overrideCredType'],
emits: ['credentialSelected'],
setup(props, { emit }) {
return () =>
h(
'button',
{
'data-workflow-id': props.workflowId,
'data-project-id': props.projectId,
onClick: () =>
emit('credentialSelected', {
properties: {
credentials: { [props.overrideCredType]: { id: 'existing', name: 'Existing' } },
},
}),
},
'Select existing connection',
);
},
});
const item = {
id: 'wf:credential:serviceApi',
kind: 'credential',
credentialType: 'serviceApi',
nodeBindings: [{ nodeName: 'Service' }],
} as const;
const node = createTestNode({ name: 'Service', type: 'test.service' });
const savedCredential = mock<ICredentialsResponse>({
isResolvable: false,
id: 'new-credential',
name: 'Service account',
type: 'serviceApi',
});
const renderComponent = createComponentRenderer(InstanceAiSetupCredential, {
props: {
item: { ...item, nodeBindings: [...item.nodeBindings] },
node,
nodes: [node],
workflowId: 'wf',
projectId: 'workflow-project',
},
global: {
stubs: {
CredentialInputs: InputStub,
TemplatedAuthSimpleView: InputStub,
NodeCredentials: ExistingCredentialStub,
},
},
});
describe('InstanceAiSetupCredential', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
form = createForm();
gateway = {
isEnabled: ref(false),
balance: ref(10),
isCredentialTypeSupported: vi.fn().mockReturnValue(true),
isNodeTypeVersionSupported: vi.fn().mockReturnValue(true),
isActionSupported: vi.fn().mockReturnValue(true),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
};
mocks.isOAuth.mockReturnValue(false);
mocks.canQuickConnect.mockReturnValue(false);
mocks.quickOption.mockReturnValue(undefined);
mocks.authorize.mockResolvedValue(savedCredential);
mockedStore(useCredentialsStore).fetchUsableCredentials.mockResolvedValue([]);
});
async function openMenu(rendered: ReturnType<typeof renderComponent>, label: string) {
await userEvent.click(rendered.getByRole('button', { name: /More options|Change connection/ }));
await userEvent.click(await rendered.findByRole('menuitem', { name: label }));
}
it('offers per-node accounts only after a shared credential has been selected', async () => {
const view = renderComponent({ props: { allowPerNode: true } });
await userEvent.click(view.getByRole('button', { name: 'More options' }));
expect(
view.queryByRole('menuitem', { name: 'Set credentials per node' }),
).not.toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await view.rerender({
node: {
...node,
credentials: { serviceApi: { id: savedCredential.id, name: savedCredential.name } },
},
});
await openMenu(view, 'Set credentials per node');
expect(view.emitted('setCredentialsPerNode')).toHaveLength(1);
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
});
it('shows the form without an action spinner while its credential name loads', async () => {
const initialization = Promise.withResolvers<void>();
form.initialize.mockReturnValueOnce(initialization.promise);
const view = renderComponent({ global: { stubs: { CredentialInputs: false } } });
expect(view.getByLabelText('API key')).toBeVisible();
expect(view.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(view.queryByText(/This workflow needs/)).not.toBeInTheDocument();
expect(view.getByRole('button', { name: 'Save' })).not.toHaveAttribute('aria-busy', 'true');
initialization.resolve();
await flushPromises();
});
it('keeps the default GitHub server and saves its two empty fields inline', async () => {
form.credentialProperties.value = [
{
name: 'server',
displayName: 'GitHub Server',
type: 'string',
default: 'https://api.github.com',
},
{ name: 'user', displayName: 'User', type: 'string', default: '' },
{
name: 'accessToken',
displayName: 'Access Token',
type: 'string',
default: '',
typeOptions: { password: true },
},
];
form.credentialData.value = {
server: 'https://api.github.com',
apiKey: 'fixture-required-value',
};
const store = mockedStore(useCredentialsStore);
store.createNewCredential.mockResolvedValue(savedCredential);
const view = renderComponent({ global: { stubs: { CredentialInputs: false } } });
await flushPromises();
expect(view.queryByLabelText('GitHub Server')).not.toBeInTheDocument();
await fireEvent.update(view.getByLabelText('User'), 'example-user');
await fireEvent.update(view.getByLabelText('Access Token'), 'example-token');
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(store.createNewCredential).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
server: 'https://api.github.com',
user: 'example-user',
accessToken: 'example-token',
}),
}),
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
expect(mockedStore(useUIStore).openNewCredential).not.toHaveBeenCalled();
});
it('includes visible OAuth fields and instance availability in its help request', async () => {
mocks.isOAuth.mockReturnValue(true);
form.credentialProperties.value = [
{ name: 'clientId', displayName: 'Client ID', type: 'string', default: '', required: true },
{
name: 'clientSecret',
displayName: 'Client Secret',
type: 'string',
default: '',
required: true,
},
];
const view = renderComponent();
await fireEvent.click(view.getByRole('button', { name: 'Help me set this up' }));
expect(view.emitted<[unknown]>('askForHelp')?.[0]?.[0]).toMatchObject({
setupContext:
"The OAuth form asks for: Client ID, Client Secret. Managed OAuth isn't available on this instance. Help me configure my own OAuth app.",
});
});
it.each([1, 2])(
'saves %s inline inputs when legacy metadata omits required flags',
async (count) => {
form.credentialProperties.value = [
...[
{ name: 'apiKey', displayName: 'API key', type: 'string' as const, default: '' },
{ name: 'apiUrl', displayName: 'API URL', type: 'string' as const, default: '' },
].slice(0, count),
...DOMAIN_RESTRICTION_FIELDS,
{ name: 'hidden', displayName: 'Hidden', type: 'hidden', default: '' },
{ name: 'notice', displayName: 'Help text', type: 'notice', default: '' },
{
name: 'callback',
displayName: 'Callback URL',
type: 'string',
default: '',
typeOptions: { copyButton: true },
},
];
form.credentialData.value.allowedHttpRequestDomains = 'domains';
form.credentialData.value.allowedDomains = 'example.test';
const store = mockedStore(useCredentialsStore);
store.createNewCredential.mockResolvedValue(savedCredential);
const rendered = renderComponent({ global: { stubs: { CredentialInputs: false } } });
await flushPromises();
expect(rendered.getAllByRole('textbox')).toHaveLength(count);
await fireEvent.update(rendered.getByLabelText('API key'), 'submitted-key');
if (count === 2)
await fireEvent.update(rendered.getByLabelText('API URL'), 'https://example.test');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(store.createNewCredential).toHaveBeenCalledWith(
expect.objectContaining({
data: {
apiKey: 'submitted-key',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'example.test',
...(count === 2 ? { apiUrl: 'https://example.test' } : {}),
},
}),
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(
savedCredential.id,
);
expect(mockedStore(useUIStore).openNewCredential).not.toHaveBeenCalled();
},
);
it.each([0, 3])('uses the modal when legacy metadata has %s editable inputs', async (count) => {
form.credentialProperties.value = [
{ name: 'apiKey', displayName: 'API key', type: 'string' as const, default: '' },
{ name: 'apiUrl', displayName: 'API URL', type: 'string' as const, default: '' },
{ name: 'region', displayName: 'Region', type: 'string' as const, default: '' },
].slice(0, count);
const rendered = renderComponent({ global: { stubs: { CredentialInputs: false } } });
await flushPromises();
expect(rendered.queryAllByRole('textbox')).toHaveLength(0);
await fireEvent.click(rendered.getByRole('button', { name: 'Connect' }));
expect(mockedStore(useUIStore).openNewCredential).toHaveBeenCalled();
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
});
it('shows required fields and tests the key before publishing and binding it in the workflow project', async () => {
const rendered = renderComponent();
const store = mockedStore(useCredentialsStore);
store.createNewCredential.mockResolvedValue(savedCredential);
const test = Promise.withResolvers<void>();
form.testCredential.mockReturnValue(test.promise);
await flushPromises();
expect(rendered.getByTestId('fields')).toHaveTextContent('apiKey');
expect(rendered.getByTestId('fields')).not.toHaveTextContent('optional');
expect(rendered.getByTestId('fields')).not.toHaveTextContent('hidden');
expect(rendered.getByRole('button', { name: 'Save' })).toBeDisabled();
await fireEvent.update(rendered.getByLabelText('API key'), 'submitted-key');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(store.createNewCredential).toHaveBeenCalledWith(
expect.objectContaining({ data: { apiKey: 'submitted-key' } }),
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
expect(rendered.emitted<[unknown, string]>('bindCredential')).toBeUndefined();
expect(store.upsertCredential).not.toHaveBeenCalled();
test.resolve();
await flushPromises();
expect(store.fetchUsableCredentials).toHaveBeenCalledWith({ workflowId: 'wf' });
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0]).toEqual([
item,
savedCredential.id,
]);
});
it('removes a failed credential and keeps the key available for correction and retry', async () => {
const rendered = renderComponent();
const store = mockedStore(useCredentialsStore);
store.createNewCredential.mockResolvedValue(savedCredential);
form.testedSuccessfully.value = false;
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'wrong');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(rendered.emitted<[unknown, string]>('bindCredential')).toBeUndefined();
expect(store.deleteCredential).toHaveBeenCalledWith({ id: savedCredential.id });
expect(rendered.getByLabelText('API key')).toHaveValue('wrong');
form.testedSuccessfully.value = true;
await fireEvent.update(rendered.getByLabelText('API key'), 'correct');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(store.createNewCredential).toHaveBeenCalledTimes(2);
expect(store.createNewCredential).toHaveBeenLastCalledWith(
expect.objectContaining({ data: { apiKey: 'correct' } }),
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
expect(rendered.emitted<[unknown, string]>('bindCredential')).toHaveLength(1);
});
it('saves client credentials without starting an interactive OAuth flow', async () => {
mocks.isOAuth.mockReturnValue(true);
mocks.canQuickConnect.mockReturnValue(true);
form.credentialData.value.grantType = 'clientCredentials';
const rendered = renderComponent();
mockedStore(useCredentialsStore).createNewCredential.mockResolvedValue(savedCredential);
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'client-secret');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(mocks.authorize).not.toHaveBeenCalled();
expect(rendered.emitted('bindCredential')).toEqual([[item, savedCredential.id]]);
});
it('binds a saved key even if refreshing the picker fails', async () => {
const rendered = renderComponent();
const store = mockedStore(useCredentialsStore);
store.createNewCredential.mockResolvedValue(savedCredential);
store.fetchUsableCredentials.mockRejectedValueOnce(new Error('Refresh failed'));
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'key');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(store.createNewCredential).toHaveBeenCalledOnce();
expect(rendered.emitted('bindCredential')).toEqual([[item, savedCredential.id]]);
});
it.each([false, true])(
'preserves a later edit when a pending credential save completes, OAuth: %s',
async (isOAuth) => {
mocks.isOAuth.mockReturnValue(isOAuth);
const request = Promise.withResolvers<ICredentialsResponse>();
mocks.authorize.mockReturnValue(request.promise);
const rendered = renderComponent();
mockedStore(useCredentialsStore).createNewCredential.mockReturnValue(request.promise);
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'first');
await fireEvent.click(
rendered.getByRole('button', { name: isOAuth ? 'Save and sign in' : 'Save' }),
);
await fireEvent.update(rendered.getByLabelText('API key'), 'later');
request.resolve(savedCredential);
await flushPromises();
await rendered.rerender({
node: {
...node,
credentials: { serviceApi: { id: savedCredential.id, name: savedCredential.name } },
},
});
expect(rendered.getByLabelText('API key')).toHaveValue('later');
expect(
rendered.getByRole('button', { name: isOAuth ? 'Save and sign in' : 'Save' }),
).toBeEnabled();
},
);
it('reuses managed OAuth and leaves the current account unchanged on cancellation', async () => {
mocks.isOAuth.mockReturnValue(true);
mocks.canQuickConnect.mockReturnValue(true);
mocks.authorize.mockResolvedValue(null);
const rendered = renderComponent({
props: {
node: { ...node, credentials: { serviceApi: { id: 'existing', name: 'Current account' } } },
},
});
await flushPromises();
expect(form.initialize).not.toHaveBeenCalled();
await openMenu(rendered, 'Switch account');
expect(mocks.authorize).toHaveBeenCalledWith(
'serviceApi',
'test.service',
expect.objectContaining({ workflowId: 'wf', projectId: 'workflow-project' }),
);
expect(rendered.getByText('Current account')).toBeVisible();
expect(rendered.emitted<[unknown, string]>('bindCredential')).toBeUndefined();
});
it('offers managed OAuth as an alternative to an advanced custom OAuth connection', async () => {
mocks.isOAuth.mockReturnValue(true);
mocks.canQuickConnect.mockReturnValue(true);
mocks.authorize.mockResolvedValue(null);
mockedStore(useCredentialsStore).getCredentialData.mockResolvedValue({
...savedCredential,
data: { clientId: 'custom-client', clientSecret: 'custom-secret' },
});
const rendered = renderComponent({
props: {
node: {
...node,
credentials: { serviceApi: { id: savedCredential.id, name: 'Custom account' } },
},
},
});
await flushPromises();
await openMenu(rendered, 'Connect with OAuth instead');
expect(mockedStore(useCredentialsStore).getCredentialData).toHaveBeenCalledWith({
id: savedCredential.id,
});
expect(mocks.authorize).toHaveBeenCalledWith(
'serviceApi',
node.type,
expect.objectContaining({ data: undefined }),
);
expect(rendered.getByText('Custom account')).toBeVisible();
expect(rendered.emitted('bindCredential')).toBeUndefined();
});
it('reuses API-key quick connect with the setup workflow and project', async () => {
mocks.quickOption.mockReturnValue({});
mocks.quickConnect.mockResolvedValue(savedCredential);
const rendered = renderComponent();
await flushPromises();
await fireEvent.click(rendered.getByRole('button', { name: 'Connect' }));
await flushPromises();
expect(mocks.quickConnect).toHaveBeenCalledWith({
credentialTypeName: 'serviceApi',
nodeType: node.type,
serviceName: 'Service',
source: 'credential_type',
projectId: 'workflow-project',
workflowId: 'wf',
});
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(savedCredential.id);
});
it('passes self-hosted OAuth form data and callback context to the shared flow', async () => {
mocks.isOAuth.mockReturnValue(true);
const rendered = renderComponent();
mockedStore(useRootStore).OAuthCallbackUrls = { oauth2: 'https://example.com/oauth2/callback' };
await flushPromises();
expect(rendered.getByDisplayValue('https://example.com/oauth2/callback')).toBeVisible();
await fireEvent.update(rendered.getByLabelText('API key'), 'client-secret');
await fireEvent.click(rendered.getByRole('button', { name: 'Save and sign in' }));
await flushPromises();
expect(mocks.authorize).toHaveBeenCalledWith('serviceApi', 'test.service', {
workflowId: 'wf',
projectId: 'workflow-project',
data: { apiKey: 'client-secret' },
name: 'Service account',
});
});
it('opens Advanced setup with the recipe and binds its created credential', async () => {
const setupHint = {
template: { headers: { Authorization: '{{api_key}}' } },
placeholders: [{ name: 'api_key', title: 'API key' }],
};
form.credentialData.value.template = JSON.stringify(setupHint.template);
form.credentialData.value.placeholderDefs = JSON.stringify(setupHint.placeholders);
const rendered = renderComponent({
props: {
item: {
...item,
nodeBindings: [],
credentialType: TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
setupHint,
},
},
});
await flushPromises();
expect(mocks.formOptions.mock.calls[0][0].setupHint()).toEqual(setupHint);
await openMenu(rendered, 'Advanced setup');
const ui = mockedStore(useUIStore);
expect(ui.openNewCredential).toHaveBeenCalledWith(
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
false,
true,
'workflow-project',
undefined,
'Service',
node,
expect.objectContaining({
credentialSetupHint: setupHint,
workflowId: 'wf',
closeOnSave: true,
}),
);
ui.openNewCredential.mock.calls[0][7]?.onCredentialCreated?.(savedCredential);
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(savedCredential.id);
});
it.each(['', JSON.stringify({ headers: { 'X-Client': 'n8n' } })])(
'opens Advanced setup directly when the custom-auth template has no input markers: %s',
async (template) => {
form.credentialData.value.template = template;
const rendered = renderComponent({
props: {
item: {
...item,
nodeBindings: [...item.nodeBindings],
credentialType: TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
},
},
global: { stubs: { TemplatedAuthSimpleView: false } },
});
await flushPromises();
expect(rendered.queryByTestId('templated-auth-simple-view')).not.toBeInTheDocument();
expect(rendered.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(rendered.queryByRole('button', { name: 'More options' })).not.toBeInTheDocument();
await fireEvent.click(rendered.getByRole('button', { name: 'Advanced setup' }));
const ui = mockedStore(useUIStore);
expect(ui.openNewCredential).toHaveBeenCalledWith(
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
false,
true,
'workflow-project',
undefined,
'Service',
node,
expect.objectContaining({ workflowId: 'wf', closeOnSave: true }),
);
ui.openNewCredential.mock.calls[0][7]?.onCredentialCreated?.(savedCredential);
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(
savedCredential.id,
);
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
},
);
it('recovers initialization failures and cancels pending connections when closed', async () => {
form.initialize.mockRejectedValueOnce(new Error('offline'));
const rendered = renderComponent();
await flushPromises();
expect(rendered.getByRole('button', { name: 'Save' })).toBeDisabled();
await fireEvent.click(rendered.getByRole('button', { name: 'Retry' }));
await flushPromises();
expect(form.initialize).toHaveBeenCalledTimes(2);
expect(rendered.queryByRole('button', { name: 'Retry' })).toBeNull();
rendered.unmount();
expect(mocks.cancelAuthorize).toHaveBeenCalled();
expect(mocks.cancelConnect).toHaveBeenCalled();
});
it.each([false, true])(
'reuses the scoped picker for existing and private credentials, private: %s',
async (isPrivate) => {
const store = mockedStore(useCredentialsStore);
const credential = { ...savedCredential, isResolvable: isPrivate };
store.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
store.getUsableCredentialByType = vi.fn().mockReturnValue([credential]);
store.getCredentialById = vi.fn().mockReturnValue(credential);
const rendered = renderComponent({
props: {
node: isPrivate
? {
...node,
credentials: { serviceApi: { id: savedCredential.id, name: savedCredential.name } },
}
: node,
},
});
await flushPromises();
expect(rendered.queryByLabelText('API key')).toBeNull();
const picker = rendered.getByRole('button', { name: 'Select existing connection' });
expect(picker).toHaveAttribute('data-project-id', 'workflow-project');
expect(picker).toHaveAttribute('data-workflow-id', 'wf');
await fireEvent.click(picker);
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe('existing');
expect(store.createNewCredential).not.toHaveBeenCalled();
},
);
it('offers an existing credential before the announced workflow node exists', async () => {
const store = mockedStore(useCredentialsStore);
store.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
store.getUsableCredentialByType = vi.fn().mockReturnValue([savedCredential]);
const rendered = renderComponent({ props: { node: undefined, nodes: [] } });
await flushPromises();
expect(rendered.queryByLabelText('API key')).toBeNull();
await userEvent.click(rendered.getByRole('combobox'));
const option = rendered.getByRole('option', { name: /Service account/ });
expect(rendered.container.contains(option)).toBe(false);
await userEvent.click(option);
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(savedCredential.id);
expect(store.createNewCredential).not.toHaveBeenCalled();
await rendered.rerender({
pendingCredential: { id: savedCredential.id, name: savedCredential.name },
});
expect(rendered.getByTitle(savedCredential.name)).toBeVisible();
expect(rendered.getByRole('status')).toHaveTextContent('Credential selected');
expect(rendered.queryByLabelText('API key')).toBeNull();
});
it('asks for help with service and field context without submitting the key', async () => {
const rendered = renderComponent();
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'private-draft');
await userEvent.click(rendered.getByRole('button', { name: 'Help me find my API key' }));
expect(rendered.emitted('askForHelp')).toEqual([
[
expect.objectContaining({
credentialType: 'serviceApi',
displayName: 'Service',
placeholderTitles: ['API key'],
documentationUrl: expect.stringContaining('/service/'),
}),
],
]);
expect(JSON.stringify(rendered.emitted('askForHelp'))).not.toContain('private-draft');
expect(rendered.getByLabelText('API key')).toHaveValue('private-draft');
await rendered.rerender({ helpDisabled: true });
expect(rendered.getByRole('button', { name: 'Help me find my API key' })).toBeDisabled();
});
it.each([false, true])(
'lets users return to their existing credential through the menu, credits: %s',
async (useCredits) => {
const store = mockedStore(useCredentialsStore);
store.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
store.getUsableCredentialByType = vi.fn().mockReturnValue([savedCredential]);
const rendered = renderComponent({
props: { pendingCredential: { id: savedCredential.id, name: savedCredential.name } },
});
await flushPromises();
await userEvent.click(rendered.getByRole('button', { name: 'Change connection' }));
expect(rendered.queryByRole('menuitem', { name: 'Use an existing credential' })).toBeNull();
await userEvent.click(rendered.getByRole('menuitem', { name: 'Create new credential' }));
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'draft-key');
if (useCredits) {
gateway.isEnabled.value = true;
await flushPromises();
await userEvent.click(rendered.getByRole('radio', { name: 'Gateway credits' }));
}
expect(rendered.queryByRole('button', { name: 'Use an existing credential' })).toBeNull();
await openMenu(rendered, 'Use an existing credential');
expect(rendered.getByRole('button', { name: 'Select existing connection' })).toBeVisible();
await userEvent.click(rendered.getByRole('button', { name: 'Cancel' }));
if (useCredits)
await userEvent.click(rendered.getByRole('radio', { name: 'Use my API key' }));
expect(rendered.getByLabelText('API key')).toHaveValue('draft-key');
await openMenu(rendered, 'Use an existing credential');
await userEvent.click(rendered.getByRole('button', { name: 'Select existing connection' }));
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe('existing');
expect(store.createNewCredential).not.toHaveBeenCalled();
},
);
it('reconciles a newly saved binding without opening a new OAuth form', async () => {
mocks.isOAuth.mockReturnValue(true);
const rendered = renderComponent();
await flushPromises();
expect(rendered.getByLabelText('API key')).toBeVisible();
await rendered.rerender({
node: { ...node, credentials: { serviceApi: { id: 'existing', name: 'Existing account' } } },
});
expect(rendered.queryByLabelText('API key')).toBeNull();
expect(rendered.getByText('Existing account')).toBeVisible();
expect(mocks.authorize).not.toHaveBeenCalled();
});
it('does not bind a pending API key after its detail has closed', async () => {
const pending = Promise.withResolvers<ICredentialsResponse>();
const rendered = renderComponent();
mockedStore(useCredentialsStore).createNewCredential.mockReturnValue(pending.promise);
await flushPromises();
await fireEvent.update(rendered.getByLabelText('API key'), 'key');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
rendered.unmount();
pending.resolve(savedCredential);
await flushPromises();
expect(rendered.emitted<[unknown, string]>('bindCredential')).toBeUndefined();
expect(mockedStore(useCredentialsStore).fetchUsableCredentials).not.toHaveBeenCalled();
});
it('binds Gateway credits as a managed selection and allows switching to an own key', async () => {
gateway.isEnabled.value = true;
const rendered = renderComponent();
await flushPromises();
await fireEvent.click(rendered.getByRole('button', { name: 'Use credits' }));
expect(rendered.emitted<[unknown, string]>('bindCredential')?.[0][1]).toBe(
AI_GATEWAY_MANAGED_TAG,
);
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
await rendered.rerender({
node: {
...node,
credentials: { serviceApi: { id: null, name: '', __aiGatewayManaged: true } },
},
});
await openMenu(rendered, 'Use my API key');
await flushPromises();
expect(rendered.getByLabelText('API key')).toBeVisible();
expect(rendered.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it.each([
{ reason: 'version', reverse: false },
{ reason: 'version', reverse: true },
{ reason: 'action', reverse: false },
{ reason: 'unsupported', reverse: false },
])('omits Gateway when a node fails $reason, reversed: $reverse', async ({ reason, reverse }) => {
gateway.isEnabled.value = true;
if (reason === 'version')
gateway.isNodeTypeVersionSupported.mockImplementation(
(_type: string, version: number) => version === 1,
);
if (reason === 'action') gateway.isActionSupported.mockReturnValue(false);
const boundNode = {
...node,
type: reason === 'unsupported' ? 'n8n-nodes-base.httpRequest' : node.type,
typeVersion: 2,
parameters: { operation: 'send' },
};
const nodes = [{ ...node, typeVersion: 1 }, boundNode];
const rendered = renderComponent({ props: { nodes: reverse ? nodes.toReversed() : nodes } });
await flushPromises();
expect(rendered.queryByRole('button', { name: 'Use credits' })).toBeNull();
expect(rendered.getByRole('button', { name: 'Save' })).toBeVisible();
});
});

View File

@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { computed, defineComponent, h, reactive, type PropType } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { getActivePinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { ResponseError } from '@n8n/rest-api-client';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import {
deepCopy,
NodeConnectionTypes,
@ -13,7 +16,12 @@ import {
type INodeTypeDescription,
type AssignmentCollectionValue,
} from 'n8n-workflow';
import type { InstanceAiAgentNode, InstanceAiCredentialSetupHint } from '@n8n/api-types';
import type {
InstanceAiAgentNode,
InstanceAiCredentialSetupHint,
InstanceAiSetupItem,
PushMessage,
} from '@n8n/api-types';
import { createComponentRenderer, type RenderOptions } from '@/__tests__/render';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
@ -26,6 +34,10 @@ import {
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { getWorkflowExecutionStateStoreId } from '@/app/stores/workflowExecutionState.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { SETUP_PANEL_SUCCESS_DELAY } from '@/app/constants/durations';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useUIStore } from '@/app/stores/ui.store';
@ -33,18 +45,27 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
import type { SetupPanelThreadSource } from '../../../composables/useSetupPanelState';
import type { SetupPanelThreadActions } from '../../../composables/useSetupPanelActions';
import type { ThreadRuntime } from '../../../instanceAi.store';
import InstanceAiSetupPanel from '../InstanceAiSetupPanel.vue';
const { showMessage, testCredentialInBackground } = vi.hoisted(() => ({
const { showMessage, testCredentialInBackground, authorize } = vi.hoisted(() => ({
showMessage: vi.fn(),
testCredentialInBackground: vi.fn().mockResolvedValue(undefined),
authorize: vi.fn(),
}));
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }) }));
vi.mock('@/features/credentials/composables/useCredentialTestInBackground', () => ({
useCredentialTestInBackground: () => ({ testCredentialInBackground }),
}));
vi.mock('@/features/credentials/composables/useCredentialOAuth', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/features/credentials/composables/useCredentialOAuth')>();
return {
...actual,
useCredentialOAuth: () => ({ ...actual.useCredentialOAuth(), authorize }),
};
});
vi.mock('@/app/api/workflows', async (importOriginal) => ({
...(await importOriginal<object>()),
getWorkflow: vi.fn(),
@ -61,7 +82,8 @@ vi.mock('@/app/composables/useNodeHelpers', async (importOriginal) => {
};
});
let thread: SetupPanelThreadSource & SetupPanelThreadActions;
let thread: SetupPanelThreadSource &
Pick<ThreadRuntime, 'id' | 'sendMessage' | 'rememberManualExecution'>;
vi.mock('../../../instanceAi.store', () => ({ useThread: () => thread }));
const accounts = [
@ -116,6 +138,50 @@ const NodeCredentialsStub = defineComponent({
},
});
const SetupCredentialStub = defineComponent({
props: {
item: {
type: Object as PropType<Extract<InstanceAiSetupItem, { kind: 'credential' }>>,
required: true,
},
node: Object as PropType<INodeUi>,
workflowId: String,
projectId: String,
allowPerNode: Boolean,
},
emits: ['bindCredential', 'setCredentialsPerNode'],
setup(props, { emit }) {
return () =>
props.node
? h('div', [
props.allowPerNode
? h(
'button',
{ onClick: () => emit('setCredentialsPerNode') },
'Set credentials per node',
)
: null,
h(NodeCredentialsStub, {
node: props.node,
overrideCredType: props.item.credentialType,
credentialSetupHint: props.item.setupHint,
workflowId: props.workflowId,
projectId: props.projectId,
skipAutoSelect: true,
onCredentialSelected: (update: {
properties: { credentials: Record<string, { id: string }> };
}) =>
emit(
'bindCredential',
props.item,
update.properties.credentials[props.item.credentialType].id,
),
}),
])
: null;
},
});
const ParameterInputListStub = defineComponent({
props: {
node: { type: Object as PropType<INodeUi>, required: true },
@ -147,6 +213,7 @@ const ParameterInputListStub = defineComponent({
});
const componentStubs = {
InstanceAiSetupCredential: SetupCredentialStub,
NodeCredentials: NodeCredentialsStub,
ParameterInputList: ParameterInputListStub,
CredentialIcon: true,
@ -166,6 +233,7 @@ describe('InstanceAiSetupPanel interactions', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setActivePinia(createTestingPinia({ stubActions: false }));
mockedStore(useProjectsStore).myProjects = [mock<ProjectListItem>({ id: 'project-1' })];
const nodeTypes = mockedStore(useNodeTypesStore);
@ -200,6 +268,8 @@ describe('InstanceAiSetupPanel interactions', () => {
const credentials = mockedStore(useCredentialsStore);
credentials.setCredentials(accounts);
credentials.fetchUsableCredentials.mockResolvedValue([]);
credentials.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
credentials.getNewCredentialName.mockResolvedValue('Slack account');
credentials.getCredentialTypeByName = vi.fn().mockReturnValue({
name: 'slackApi',
displayName: 'Slack API',
@ -230,6 +300,7 @@ describe('InstanceAiSetupPanel interactions', () => {
fetchWorkflow.mockImplementation(async () => deepCopy(saved));
documentStore = useWorkflowDocumentStore(createWorkflowDocumentId('wf-1'));
thread = reactive({
id: 'thread-1',
messages: [],
setupItemsByWorkflowId: {
'wf-1': [
@ -237,6 +308,7 @@ describe('InstanceAiSetupPanel interactions', () => {
],
},
sendMessage: vi.fn().mockResolvedValue(true),
rememberManualExecution: vi.fn(),
});
});
@ -277,15 +349,117 @@ describe('InstanceAiSetupPanel interactions', () => {
hydrated = true,
options: RenderOptions<typeof InstanceAiSetupPanel> = {},
) {
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
const rendered = renderPanel(hydrated, options);
await fireEvent.click(await rendered.findByRole('button', { name: 'Notify' }));
await fireEvent.click(await rendered.findByRole('button', { name: /Slack/ }));
return rendered;
}
it('preserves drafts when splitting a shared account and saves both nodes with one Confirm', async () => {
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
saved.nodes.push({
...deepCopy(saved.nodes[0]),
id: 'second-node',
name: 'Second notification',
});
const view = await openParameters();
const channels = view.getAllByLabelText('Channel');
await fireEvent.update(channels[0], 'first-channel');
await fireEvent.update(channels[1], 'second-channel');
expect(view.getAllByLabelText('Account')).toHaveLength(1);
await fireEvent.click(view.getByRole('button', { name: 'Set credentials per node' }));
expect(updateWorkflow).not.toHaveBeenCalled();
expect(
view.getAllByLabelText('Channel').map((input) => (input as HTMLInputElement).value),
).toEqual(['first-channel', 'second-channel']);
const accounts = view.getAllByLabelText('Account');
expect(accounts).toHaveLength(2);
await fireEvent.update(accounts[1], 'cred-2');
await flushPromises();
expect(saved.nodes.map((node) => node.credentials?.slackApi.id)).toEqual(['cred-1', 'cred-2']);
updateWorkflow.mockClear();
expect(view.getAllByRole('button', { name: 'Confirm' })).toHaveLength(1);
await fireEvent.click(view.getByRole('button', { name: 'Confirm' }));
await flushPromises();
expect(updateWorkflow).toHaveBeenCalledTimes(1);
expect(saved.nodes.map((node) => node.parameters.channel)).toEqual([
'first-channel',
'second-channel',
]);
});
it('opens existing different accounts per node and preserves all drafts after a failed batch', async () => {
saved.nodes.push({
...deepCopy(saved.nodes[0]),
id: 'second-node',
name: 'Second notification',
credentials: { slackApi: { id: 'cred-2', name: 'Second account' } },
});
const view = await openParameters();
expect(
view.getAllByLabelText('Account').map((input) => (input as HTMLSelectElement).value),
).toEqual(['cred-1', 'cred-2']);
const channels = view.getAllByLabelText('Channel');
await fireEvent.update(channels[0], 'first-channel');
await fireEvent.update(channels[1], 'second-channel');
updateWorkflow.mockRejectedValueOnce(new Error('offline'));
await fireEvent.click(view.getByRole('button', { name: 'Confirm' }));
await flushPromises();
expect(saved.nodes.map((node) => node.parameters.channel)).toEqual(['', '']);
expect(
view.getAllByLabelText('Channel').map((input) => (input as HTMLInputElement).value),
).toEqual(['first-channel', 'second-channel']);
await fireEvent.click(view.getByRole('button', { name: 'Confirm' }));
await flushPromises();
expect(saved.nodes.map((node) => node.parameters.channel)).toEqual([
'first-channel',
'second-channel',
]);
});
it('holds confirmed fields briefly, then preserves the completed review when an edit is saved', async () => {
const defaultMatchMedia = window.matchMedia;
const media = window.matchMedia('');
window.matchMedia = vi.fn((query) => ({ ...media, media: query, matches: false }));
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
saved.nodes[0].parameters.options = { value: 'configured', other: 'Keep this option' };
const view = await openParameters();
await fireEvent.update(view.getByLabelText('Channel'), 'first-value');
await fireEvent.click(view.getByRole('button', { name: 'Confirm' }));
await flushPromises();
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY - 1);
expect(view.getByLabelText('Channel')).toHaveValue('first-value');
await vi.advanceTimersByTimeAsync(1);
expect(view.queryByRole('dialog')).toBeNull();
await fireEvent.click(view.getByRole('button', { name: 'Setup complete' }));
await vi.advanceTimersByTimeAsync(32);
await fireEvent.click(view.getByRole('button', { name: 'Slack Complete' }));
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY * 2);
expect(view.getByLabelText('Channel')).toHaveValue('first-value');
await fireEvent.update(view.getByLabelText('Channel'), 'updated-value');
await fireEvent.click(view.getByRole('button', { name: 'Update' }));
await flushPromises();
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY);
expect(view.queryByRole('dialog')).toBeNull();
expect(view.getByRole('button', { name: 'Slack Complete' })).toBeVisible();
expect(view.getByRole('button', { name: 'Setup complete' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(saved.nodes[0].parameters.channel).toBe('updated-value');
view.unmount();
} finally {
window.matchMedia = defaultMatchMedia;
vi.useRealTimers();
}
});
it('binds a credential announced without bindings after the build ends', async () => {
startBuild();
const { getByRole } = renderPanel();
await fireEvent.click(getByRole('button', { name: 'Slack' }));
const { getByRole, findByRole, getByTestId, queryByRole } = renderPanel();
await flushPromises();
await fireEvent.click(await findByRole('button', { name: /Slack/ }));
const picker = getByRole('combobox');
expect(picker).toHaveAttribute('data-workflow-id', 'wf-1');
expect(picker).toHaveAttribute('data-project-id', 'project-1');
@ -293,6 +467,13 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(updateWorkflow).not.toHaveBeenCalled();
await fireEvent.change(picker, { target: { value: 'cred-2' } });
expect(updateWorkflow).not.toHaveBeenCalled();
expect(getByTestId('selected-account')).toHaveTextContent('Second account');
await fireEvent.click(getByRole('button', { name: 'Back to setup checklist' }));
await fireEvent.click(getByRole('button', { name: 'Slack Complete' }));
expect(getByRole('combobox')).toHaveValue('cred-2');
expect(queryByRole('button', { name: 'Execute' })).toBeNull();
// The user's explicit choice replaces the SDK's selection when writes resume.
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
thread.messages = [];
await flushPromises();
expect(updateWorkflow).toHaveBeenCalledTimes(1);
@ -300,6 +481,147 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(testCredentialInBackground).toHaveBeenCalledWith('cred-2', 'Second account', 'slackApi');
});
it.each([false, true])(
'uses the SDK-selected credential before the build ends, canvas: %s',
async (hydrated) => {
const read = Promise.withResolvers<IWorkflowDb>();
fetchWorkflow.mockReturnValueOnce(read.promise);
startBuild();
const { queryByRole, queryByTestId } = renderPanel(hydrated);
await flushPromises();
expect(queryByTestId('instance-ai-setup-panel')).toBeNull();
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
read.resolve(deepCopy(saved));
await flushPromises();
expect(queryByRole('button', { name: /Add connection/ })).toBeNull();
expect(queryByTestId('instance-ai-setup-panel')).toBeNull();
expect(updateWorkflow).not.toHaveBeenCalled();
},
);
it('keeps private Connect available after an unrelated credential list replaces the flat map', async () => {
vi.spyOn(useSettingsStore(), 'isModuleActive').mockReturnValue(true);
const credential = mock<ICredentialsResponse>({
id: 'private-account',
name: 'Private Slack',
type: 'slackApi',
isResolvable: true,
connectedByMe: false,
scopes: ['credential:read', 'credential:connect'],
});
const credentials = mockedStore(useCredentialsStore);
credentials.setCredentials([credential]);
credentials.usableCredentials = { [credential.id]: credential };
saved.nodes[0].credentials = { slackApi: { id: credential.id, name: credential.name } };
const view = renderPanel(false, {
global: { stubs: { InstanceAiSetupCredential: false, NodeCredentials: false } },
});
await userEvent.click(await view.findByRole('button', { name: /Slack/ }));
expect(await view.findByTestId('node-credential-private-connect')).toBeEnabled();
credentials.setCredentials([]);
await flushPromises();
expect(credentials.getCredentialById(credential.id)).toBeUndefined();
expect(credentials.getUsableCredentialById(credential.id)).toMatchObject({
connectedByMe: false,
});
expect(view.getByTestId('node-credential-private-connect')).toBeEnabled();
authorize.mockResolvedValueOnce(true);
await userEvent.click(view.getByTestId('node-credential-private-connect'));
await flushPromises();
expect(authorize).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: credential.id, type: credential.type, isResolvable: true }),
);
expect(credentials.getUsableCredentialById(credential.id)).toMatchObject({
connectedByMe: true,
});
expect(view.getByTestId('node-credential-private-connected-actions')).toBeVisible();
expect(testCredentialInBackground).toHaveBeenCalledWith(
credential.id,
credential.name,
credential.type,
);
expect(updateWorkflow).not.toHaveBeenCalled();
});
it('keeps a completed workflow dismissed when its execution finishes on another artifact', async () => {
saved.nodes[0].parameters = { channel: 'ready', options: { value: 'ready' } };
saved.nodes.push(createTestNode({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' }));
const nodeTypes = mockedStore(useNodeTypesStore);
const notifyType = nodeTypes.allNodeTypes[0];
const triggerType: INodeTypeDescription = {
...notifyType,
name: 'n8n-nodes-base.manualTrigger',
group: ['trigger'],
credentials: [],
properties: [],
};
nodeTypes.getNodeType = vi.fn((type) => (type === triggerType.name ? triggerType : notifyType));
const otherWorkflow = createTestWorkflow({ id: 'wf-2', nodes: deepCopy(saved.nodes) });
vi.mocked(getWorkflow).mockImplementation(async (_context, id) =>
deepCopy(id === otherWorkflow.id ? otherWorkflow : saved),
);
fetchWorkflow.mockImplementation(async (id) =>
deepCopy(id === otherWorkflow.id ? otherWorkflow : saved),
);
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn(
(type) => type === 'n8n-nodes-base.manualTrigger',
);
const handlers = new Set<(event: PushMessage) => void>();
const push = mockedStore(usePushConnectionStore);
push.isConnected = true;
push.addEventListener.mockImplementation((handler) => {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
});
const workflows = mockedStore(useWorkflowsStore);
workflows.runWorkflow.mockResolvedValueOnce({ executionId: 'run-first' });
workflows.fetchExecutionDataById.mockResolvedValue(null);
const view = renderPanel(false);
await userEvent.click(await view.findByRole('button', { name: /Slack/ }));
await fireEvent.change(view.getByRole('combobox'), { target: { value: 'cred-1' } });
await userEvent.click(await view.findByRole('button', { name: 'Execute' }));
await flushPromises();
expect(workflows.runWorkflow).toHaveBeenCalledWith({
workflowId: 'wf-1',
triggerToStartFrom: { name: 'Start' },
});
expect(thread.sendMessage).not.toHaveBeenCalled();
await view.rerender({ workflowId: 'wf-2' });
await flushPromises();
expect(view.getByRole('button', { name: /Slack/ })).toBeVisible();
for (const handler of handlers)
handler({
type: 'executionFinished',
data: { workflowId: 'wf-1', executionId: 'run-first', status: 'success' },
});
await waitFor(() =>
expect(thread.sendMessage).toHaveBeenCalledExactlyOnceWith(
'The workflow execution finished with execution id "run-first"',
{
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: useRootStore().pushRef,
},
),
);
expect(view.getByRole('button', { name: /Slack/ })).toBeVisible();
await view.rerender({ workflowId: 'wf-1' });
await flushPromises();
expect(view.queryByTestId('instance-ai-setup-panel')).toBeNull();
view.unmount();
const restored = renderPanel(false);
await flushPromises();
expect(restored.queryByTestId('instance-ai-setup-panel')).toBeNull();
saved.nodes[0].parameters.channel = '';
await restored.rerender({ workflowId: 'wf-2' });
await flushPromises();
await restored.rerender({ workflowId: 'wf-1' });
await flushPromises();
expect(restored.getByRole('button', { name: /Slack/ })).toBeVisible();
expect(restored.queryByRole('button', { name: 'Execute' })).toBeNull();
});
it('opens a remaining bound node and passes its recipe to the picker', async () => {
startBuild();
const setupHint = {
@ -316,7 +638,8 @@ describe('InstanceAiSetupPanel interactions', () => {
},
];
const { getByRole, getByTestId } = renderPanel();
await fireEvent.click(getByRole('button', { name: 'Slack' }));
await flushPromises();
await fireEvent.click(getByRole('button', { name: /Slack/ }));
expect(getByTestId('credential-recipe')).toHaveTextContent(JSON.stringify(setupHint));
thread.messages = [];
await flushPromises();
@ -329,6 +652,7 @@ describe('InstanceAiSetupPanel interactions', () => {
it('follows saved account changes and keeps references absent from the local list', async () => {
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
const { getByRole, getByTestId } = renderPanel();
await flushPromises();
await fireEvent.click(getByRole('button', { name: /Slack/ }));
expect(getByTestId('selected-account')).toHaveTextContent('First account');
documentStore.updateNodeProperties({
@ -346,6 +670,88 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(updateWorkflow).not.toHaveBeenCalled();
});
it('renders fields gated by defaults omitted from the saved workflow', async () => {
const type = mockedStore(useNodeTypesStore).allNodeTypes[0];
type.properties.unshift({
name: 'resource',
displayName: 'Resource',
type: 'options',
default: 'message',
options: [{ name: 'Message', value: 'message' }],
});
const channel = type.properties.find((property) => property.name === 'channel')!;
channel.displayOptions = { show: { resource: ['message'] } };
channel.placeholder = 'Choose a channel';
const rendered = await openParameters(false, {
global: { stubs: { ParameterInputList: false } },
});
const input = await rendered.findByPlaceholderText('Choose a channel');
expect(input).toBeVisible();
await fireEvent.update(input, 'announcements');
await fireEvent.blur(input);
await fireEvent.click(rendered.getByRole('button', { name: 'Confirm' }));
await flushPromises();
expect(saved.nodes[0].parameters.channel).toBe('announcements');
expect(saved.nodes[0].parameters.resource).toBeUndefined();
});
it('preserves a hidden sibling when evaluating a setup field', async () => {
const type = mockedStore(useNodeTypesStore).allNodeTypes[0];
type.properties.unshift(
{ name: 'mode', displayName: 'Mode', type: 'string', default: 'basic' },
{
name: 'useChannel',
displayName: 'Use channel',
type: 'boolean',
default: true,
displayOptions: { show: { mode: ['advanced'] } },
},
);
const channel = type.properties.find((property) => property.name === 'channel')!;
channel.displayOptions = { show: { useChannel: [true] } };
channel.placeholder = 'Choose a channel';
const rendered = await openParameters(false, {
global: { stubs: { ParameterInputList: false } },
});
expect(await rendered.findByPlaceholderText('Choose a channel')).toBeVisible();
});
it('disposes the temporary execution store when the panel unmounts', async () => {
const rendered = await openParameters(false, {
global: { stubs: { ParameterInputList: false } },
});
const documentId = createWorkflowDocumentId('wf-1', 'wf-1:parameters:Notify');
useNDVStore(documentId);
const storeId = getWorkflowExecutionStateStoreId(documentId);
expect(getActivePinia()!.state.value[storeId]).toBeDefined();
rendered.unmount();
expect(getActivePinia()!.state.value[storeId]).toBeUndefined();
});
it.each(['back', 'escape'])('disposes detail stores after closing with %s', async (method) => {
const rendered = await openParameters(false, {
global: { stubs: { ParameterInputList: false, transition: false } },
});
const documentId = createWorkflowDocumentId('wf-1', 'wf-1:parameters:Notify');
const storeIds = [
useWorkflowDocumentStore(documentId).$id,
useNDVStore(documentId).$id,
getWorkflowExecutionStateStoreId(documentId),
];
for (const id of storeIds) expect(getActivePinia()!.state.value[id]).toBeDefined();
if (method === 'back') {
await userEvent.click(rendered.getByRole('button', { name: 'Back to setup checklist' }));
} else {
rendered.getByRole('button', { name: 'Back to setup checklist' }).focus();
await userEvent.keyboard('{Escape}');
}
await waitFor(() => {
for (const id of storeIds) expect(getActivePinia()!.state.value[id]).toBeUndefined();
});
expect(rendered.getByTestId('instance-ai-setup-panel')).toBeVisible();
expect(rendered.getByRole('button', { name: /Slack/ })).toBeVisible();
});
it.each([true, false])('clears saved drafts with a hydrated canvas: %s', async (hydrated) => {
const { getByRole, getByLabelText } = await openParameters(hydrated);
const confirm = getByRole('button', { name: 'Confirm' });
@ -392,8 +798,11 @@ describe('InstanceAiSetupPanel interactions', () => {
properties: { parameters: { ...saved.nodes[0].parameters, channel: 'external-value' } },
});
await flushPromises();
await fireEvent.click(getByRole('button', { name: 'Setup complete' }));
await waitFor(() => expect(getByRole('button', { name: 'Slack Complete' })).toBeVisible());
await fireEvent.click(getByRole('button', { name: 'Slack Complete' }));
expect(getByLabelText('Channel')).toHaveValue('external-value');
expect(getByRole('button', { name: 'Confirm' })).toBeDisabled();
expect(getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('keeps a failed draft available for retry', async () => {
@ -426,7 +835,11 @@ describe('InstanceAiSetupPanel interactions', () => {
await flushPromises();
expect(updateWorkflow).not.toHaveBeenCalled();
expect(getByLabelText('Channel')).toHaveValue('queued-value');
expect(getByRole('button', { name: 'Confirm' })).toBeEnabled();
expect(getByRole('button', { name: 'Confirm' })).toBeDisabled();
await fireEvent.click(getByRole('button', { name: 'Back to setup checklist' }));
await fireEvent.click(getByRole('button', { name: /Slack/ }));
expect(getByLabelText('Channel')).toHaveValue('queued-value');
expect(getByRole('button', { name: 'Confirm' })).toBeDisabled();
thread.messages = [];
await flushPromises();
expect(saved.nodes[0].parameters.channel).toBe('queued-value');
@ -451,6 +864,28 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(saved.nodes[0].parameters.channel).toBe('newer-value');
});
it('lets a queued field return to its original saved value', async () => {
const { getByRole, getByLabelText } = await openParameters(false);
thread.setupItemsByWorkflowId['wf-1'].push({
id: 'wf-1:parameters:Notify',
kind: 'parameters',
nodeName: 'Notify',
parameterNames: ['channel', 'options'],
});
startBuild();
await flushPromises();
await fireEvent.update(getByLabelText('Channel'), 'queued-value');
await fireEvent.click(getByRole('button', { name: 'Confirm' }));
await fireEvent.update(getByLabelText('Channel'), '');
expect(getByLabelText('Channel')).toHaveValue('');
expect(getByRole('button', { name: 'Confirm' })).toBeEnabled();
await fireEvent.click(getByRole('button', { name: 'Confirm' }));
expect(getByRole('button', { name: 'Confirm' })).toBeDisabled();
thread.messages = [];
await flushPromises();
expect(saved.nodes[0].parameters.channel).toBe('');
});
it('preserves saved sibling changes when a nested edit is queued', async () => {
const { getByRole, getByLabelText } = await openParameters();
await fireEvent.update(getByLabelText('Options'), 'user-value');
@ -565,17 +1000,16 @@ describe('InstanceAiSetupPanel interactions', () => {
const uiStore = mockedStore(useUIStore);
const { findByRole } = renderPanel(false, {
props: { projectId: undefined },
global: { stubs: { NodeCredentials: false } },
global: { stubs: { NodeCredentials: false, InstanceAiSetupCredential: false } },
});
await fireEvent.click(await findByRole('button', { name: 'Slack' }));
const connect = await findByRole('button', { name: 'Connect to Slack' });
expect(connect).toBeEnabled();
await fireEvent.click(connect);
await fireEvent.click(await findByRole('button', { name: /Slack/ }));
await userEvent.click(await findByRole('button', { name: 'More options' }));
await userEvent.click(await findByRole('menuitem', { name: 'Advanced setup' }));
await flushPromises();
expect(uiStore.openNewCredential).toHaveBeenCalledWith(
'slackApi',
false,
false,
true,
'workflow-project',
undefined,
'Notify',
@ -605,8 +1039,9 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(updateWorkflow).not.toHaveBeenCalled();
} else {
expect(getByLabelText('Channel')).toHaveValue('user-value');
await fireEvent.click(getByRole('button', { name: 'Back' }));
expect(getByRole('button', { name: 'Notify Ready' })).toBeInTheDocument();
await fireEvent.click(getByRole('button', { name: 'Back to setup checklist' }));
await fireEvent.click(getByRole('button', { name: 'Setup complete' }));
await waitFor(() => expect(getByRole('button', { name: 'Slack Complete' })).toBeVisible());
expect(saved.nodes[0].parameters.channel).toBe('Saved elsewhere');
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
}

View File

@ -1,35 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import type { InstanceAiSetupItem } from '@n8n/api-types';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, h, reactive, ref } from 'vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { flushPromises } from '@vue/test-utils';
import type { InstanceAiMessage, InstanceAiSetupItem } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import type { INodeUi } from '@/Interface';
import type { SetupPanelRow } from '../../../composables/useSetupPanelState';
import InstanceAiSetupPanel from '../InstanceAiSetupPanel.vue';
import ResourceLocatorDropdown from '@/features/ndv/parameters/components/ResourceLocator/ResourceLocatorDropdown.vue';
import { SETUP_PANEL_SUCCESS_DELAY } from '@/app/constants/durations';
import { AI_GATEWAY_MANAGED_TAG } from '../../../constants';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => key }),
// The shared popover mock renders inline and cannot verify the portal boundary.
vi.unmock('reka-ui');
vi.mock('../../../composables/useSetupPanelTelemetry', () => ({
useSetupPanelTelemetry: () => ({
trackConnectionStarted: vi.fn(),
trackConnectionCompleted: vi.fn(),
trackDismissed: vi.fn(),
}),
}));
const { stateMock, actionsMock, showMessageMock, testCredentialMock } = vi.hoisted(() => ({
stateMock: {
rows: [] as SetupPanelRow[],
nodesByName: {} as Record<string, INodeUi>,
refreshWorkflow: vi.fn().mockResolvedValue(undefined),
},
actionsMock: {
bindCredential: vi.fn(),
applyParameterValues: vi.fn(),
executeWorkflow: vi.fn(),
flushPendingApplies: vi.fn(),
},
showMessageMock: vi.fn(),
testCredentialMock: vi.fn(),
const { actionsMock, showMessageMock, testCredentialMock, oauthMock, credentialsMock } = vi.hoisted(
() => ({
oauthMock: {
isOAuthCredentialType: vi.fn(() => false),
canOAuthCredentialQuickConnect: vi.fn(() => false),
createAndAuthorize: vi.fn(),
cancelAuthorize: vi.fn(),
},
credentialsMock: {
getCredentialById: vi.fn(),
getUsableCredentialById: vi.fn(),
hasUsableCredentialsForScope: vi.fn(() => false),
getUsableCredentialByType: vi.fn<() => Array<{ id: string; name: string }>>(() => []),
},
actionsMock: {
bindCredential: vi.fn(),
applyParameterValues: vi.fn(),
executeWorkflow: vi.fn(),
flushPendingApplies: vi.fn(),
getPendingCredential: vi.fn(),
getPendingParameterChanges: vi.fn(() => []),
},
showMessageMock: vi.fn(),
testCredentialMock: vi.fn(),
}),
);
vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({
useCredentialOAuth: () => oauthMock,
}));
vi.mock('../../../instanceAi.store', () => ({
useThread: () => ({ messages: [], setupItemsByWorkflowId: {}, sendMessage: vi.fn() }),
}));
const stateMock = reactive({
rows: [] as SetupPanelRow[],
nodesByName: {} as Record<string, INodeUi>,
credentialsAvailable: true,
isAgentBuilding: false,
isApplying: false,
pendingApplyCount: 0,
refreshWorkflow: vi.fn().mockResolvedValue(undefined),
});
const threadMock = reactive({
id: 'thread-1',
messages: [] as InstanceAiMessage[],
isStreaming: false,
isSendingMessage: false,
isAwaitingConfirmation: false,
setupItemsByWorkflowId: {},
sendMessage: vi.fn(),
});
vi.mock('../../../instanceAi.store', () => ({ useThread: () => threadMock }));
vi.mock('../../../composables/useSetupPanelState', async () => {
const { computed } = await import('vue');
@ -37,18 +83,41 @@ vi.mock('../../../composables/useSetupPanelState', async () => {
useSetupPanelState: () => ({
rows: computed(() => stateMock.rows),
rowSource: computed(() => 'derived'),
credentialsAvailable: computed(() => stateMock.credentialsAvailable),
isRefreshingWorkflow: computed(() => false),
workflowProjectId: computed(() => undefined),
isAgentBuilding: computed(() => false),
isAgentBuilding: computed(() => stateMock.isAgentBuilding),
getNodeByName: (name: string) => stateMock.nodesByName[name],
refreshWorkflow: stateMock.refreshWorkflow,
}),
};
});
vi.mock('../../../composables/useSetupPanelExecution', () => ({
useSetupPanelExecution: () => {
const isRunning = ref(false);
return {
isRunning,
executeWorkflow: async () => {
isRunning.value = true;
try {
return await actionsMock.executeWorkflow();
} finally {
isRunning.value = false;
}
},
};
},
}));
vi.mock('../../../composables/useSetupPanelActions', async () => {
const { computed } = await import('vue');
return {
useSetupPanelActions: () => ({ ...actionsMock, pendingApplyCount: computed(() => 0) }),
useSetupPanelActions: () => ({
...actionsMock,
isApplying: computed(() => stateMock.isApplying),
pendingApplyCount: computed(() => stateMock.pendingApplyCount),
}),
};
});
@ -70,7 +139,7 @@ vi.mock('@/features/credentials/composables/useCredentialTestInBackground', () =
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
getCredentialById: vi.fn(),
...credentialsMock,
getCredentialTypeByName: (type: string) =>
type === 'notionApi' ? { displayName: 'Notion API' } : undefined,
}),
@ -108,17 +177,344 @@ const renderComponent = createComponentRenderer(InstanceAiSetupPanel, {
global: {
stubs: {
InstanceAiSetupPanelDetail: true,
InstanceAiSetupCredential: true,
CredentialIcon: true,
NodeIcon: true,
},
},
});
const defaultMatchMedia = window.matchMedia;
function enableMotionTimers() {
const media = window.matchMedia('');
window.matchMedia = vi.fn((query) => ({ ...media, media: query, matches: false }));
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
}
function renderCompletion(credentialId = 'cred-1') {
stateMock.rows = [
{ item: credentialItem, isDone: false },
{ item: parametersItem, isDone: false },
];
stateMock.nodesByName = { 'Send Slack': slackNode };
credentialsMock.getCredentialById.mockReturnValue({ id: credentialId, name: 'Account' });
actionsMock.bindCredential.mockImplementationOnce(async () => {
stateMock.rows[0].isDone = true;
return 'applied';
});
return renderComponent({
global: {
stubs: {
InstanceAiSetupPanelDetail: true,
CredentialIcon: true,
NodeIcon: true,
InstanceAiSetupCredential: defineComponent({
emits: ['bindCredential', 'update:hasChanges'],
setup(_, { emit }) {
return () =>
h('div', [
h(
'button',
{ onClick: () => emit('bindCredential', credentialItem, credentialId) },
'Save connection',
),
h('input', {
'aria-label': 'Newer credential edit',
onInput: () => emit('update:hasChanges', true),
}),
]);
},
}),
},
},
});
}
describe('InstanceAiSetupPanel', () => {
afterEach(() => {
window.matchMedia = defaultMatchMedia;
vi.useRealTimers();
});
it.each(['cred-1', AI_GATEWAY_MANAGED_TAG])(
'briefly shows successful setup and keeps a completed item open when revisited: %s',
async (credentialId) => {
enableMotionTimers();
const view = renderCompletion(credentialId);
await fireEvent.click(view.getByRole('button', { name: /Notion/ }));
await fireEvent.click(view.getByRole('button', { name: 'Save connection' }));
await flushPromises();
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY - 1);
expect(view.getByRole('dialog')).toBeVisible();
await vi.advanceTimersByTimeAsync(1);
expect(view.queryByRole('dialog')).toBeNull();
await fireEvent.click(view.getByRole('button', { name: 'Notion Complete' }));
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY * 2);
expect(view.getByRole('dialog')).toBeVisible();
},
);
it('cancels the automatic return when a newer draft appears', async () => {
enableMotionTimers();
const view = renderCompletion();
await fireEvent.click(view.getByRole('button', { name: /Notion/ }));
await fireEvent.click(view.getByRole('button', { name: 'Save connection' }));
await flushPromises();
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY / 2);
await fireEvent.update(view.getByRole('textbox', { name: 'Newer credential edit' }), 'newer');
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY * 2);
expect(view.getByRole('dialog')).toBeVisible();
});
it('does not close a reopened item when an older save finishes', async () => {
enableMotionTimers();
const view = renderCompletion();
const saved = Promise.withResolvers<string>();
actionsMock.bindCredential.mockReset().mockImplementationOnce(async () => {
stateMock.rows[0].isDone = true;
stateMock.isApplying = true;
return await saved.promise;
});
await fireEvent.click(view.getByRole('button', { name: /Notion/ }));
await fireEvent.click(view.getByRole('button', { name: 'Save connection' }));
await fireEvent.click(view.getByRole('button', { name: 'Back to setup checklist' }));
await fireEvent.click(view.getByRole('button', { name: 'Notion Complete' }));
stateMock.isApplying = false;
saved.resolve('applied');
await flushPromises();
await vi.advanceTimersByTimeAsync(SETUP_PANEL_SUCCESS_DELAY * 2);
expect(view.getByRole('dialog')).toBeVisible();
});
it('ignores a credential callback while its detail animates out', async () => {
stateMock.rows = [{ item: credentialItem, isDone: false }];
const connected = Promise.withResolvers<void>();
const view = renderComponent({
global: {
stubs: {
transition: false,
InstanceAiSetupPanelDetail: true,
CredentialIcon: true,
NodeIcon: true,
InstanceAiSetupCredential: defineComponent({
emits: ['bindCredential'],
setup(_, { emit }) {
return () =>
h(
'button',
{
onClick: () => {
void connected.promise.then(() =>
emit('bindCredential', credentialItem, 'cred-1'),
);
},
},
'Connect account',
);
},
}),
},
},
});
await fireEvent.click(view.getByRole('button', { name: /Notion/ }));
const overlay = view.getByTestId('setup-panel-overlay');
overlay.style.animationDuration = '1s';
overlay.style.animationDelay = '0s';
await fireEvent.click(view.getByRole('button', { name: 'Connect account' }));
await fireEvent.click(view.getByRole('button', { name: 'Back to setup checklist' }));
expect(overlay).toBeInTheDocument();
connected.resolve();
await flushPromises();
expect(actionsMock.bindCredential).not.toHaveBeenCalled();
view.unmount();
});
it.each(['select', 'dismiss'] as const)(
'opens resource menus outside the setup panel: %s',
async (action) => {
stateMock.rows = [{ item: parametersItem, isDone: false }];
stateMock.nodesByName = { 'Send Slack': slackNode };
const selected = vi.fn();
const view = renderComponent({
global: {
stubs: {
InstanceAiSetupPanelDetail: {
components: { ResourceLocatorDropdown },
setup: () => ({
selected,
open: ref(true),
resources: [{ name: 'Example spreadsheet', value: 'sheet-1' }],
}),
template:
'<ResourceLocatorDropdown v-model:show="open" filterable :resources="resources" @update:model-value="selected"><button>Document</button></ResourceLocatorDropdown>',
},
},
},
});
await userEvent.click(view.getByRole('button', { name: /Details/ }));
const option = await view.findByText('Example spreadsheet');
expect(view.container.contains(option)).toBe(false);
if (action === 'select') {
await userEvent.click(option);
expect(selected).toHaveBeenCalledWith('sheet-1');
} else {
await userEvent.click(view.getByPlaceholderText('Search...'));
await userEvent.keyboard('{Escape}');
await waitFor(() =>
expect(view.queryByText('Example spreadsheet')).not.toBeInTheDocument(),
);
expect(view.getByRole('button', { name: 'Back to setup checklist' })).toBeVisible();
}
},
);
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
localStorage.clear();
stateMock.credentialsAvailable = true;
stateMock.rows = [];
stateMock.isAgentBuilding = false;
stateMock.isApplying = false;
stateMock.pendingApplyCount = 0;
threadMock.messages = [];
threadMock.isStreaming = false;
threadMock.isSendingMessage = false;
threadMock.isAwaitingConfirmation = false;
actionsMock.executeWorkflow.mockResolvedValue({
workflowId: 'wf1',
executionId: 'execution-1',
status: 'success',
notified: true,
});
stateMock.nodesByName = {};
oauthMock.isOAuthCredentialType.mockReturnValue(false);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(false);
credentialsMock.getUsableCredentialByType.mockReturnValue([]);
});
async function completeSetup() {
stateMock.rows = [{ item: credentialItem, isDone: false }];
const view = renderComponent();
stateMock.rows = [{ item: credentialItem, isDone: true }];
await flushPromises();
return view;
}
it('waits for queued and active saves before offering Execute', async () => {
stateMock.pendingApplyCount = 1;
const view = await completeSetup();
expect(view.getByRole('status')).toHaveTextContent('Validating');
stateMock.pendingApplyCount = 0;
stateMock.isApplying = true;
await flushPromises();
expect(view.queryByRole('button', { name: 'Execute' })).toBeNull();
stateMock.isApplying = false;
await flushPromises();
expect(view.getByRole('button', { name: 'Execute' })).toBeEnabled();
threadMock.isStreaming = true;
await flushPromises();
expect(view.getByRole('button', { name: 'Execute' })).toBeDisabled();
});
it('keeps the checklist open when the current requirements finish during a build', async () => {
stateMock.isAgentBuilding = true;
const view = await completeSetup();
expect(view.getByRole('button', { name: 'Notion Complete' })).toBeVisible();
expect(view.queryByRole('button', { name: 'Execute' })).toBeNull();
stateMock.isAgentBuilding = false;
await flushPromises();
expect(view.getByRole('button', { name: 'Execute' })).toBeEnabled();
});
it.each(['success', 'error', 'canceled'] as const)(
'waits for the execution and notification, then stays dismissed until setup is needed: %s',
async (status) => {
const view = await completeSetup();
const result = Promise.withResolvers<{
workflowId: string;
executionId: string;
status: string;
notified: boolean;
}>();
actionsMock.executeWorkflow.mockReturnValueOnce(result.promise);
const execute = view.getByRole('button', { name: 'Execute' });
await fireEvent.click(execute);
await fireEvent.click(execute);
expect(actionsMock.executeWorkflow).toHaveBeenCalledOnce();
expect(view.getByRole('status')).toHaveTextContent('Executing');
// The agent can already be reviewing the result when notification returns.
threadMock.isStreaming = true;
result.resolve({
workflowId: 'wf1',
executionId: 'execution-1',
status,
notified: true,
});
await flushPromises();
expect(view.queryByTestId('instance-ai-setup-panel')).toBeNull();
view.unmount();
const restored = renderComponent();
await flushPromises();
expect(restored.queryByTestId('instance-ai-setup-panel')).toBeNull();
stateMock.rows = [];
await flushPromises();
stateMock.rows = [
{ item: credentialItem, isDone: true },
{ item: parametersItem, isDone: true },
];
await flushPromises();
expect(restored.queryByTestId('instance-ai-setup-panel')).toBeNull();
stateMock.rows[1].isDone = false;
await flushPromises();
expect(restored.getByTestId('instance-ai-setup-panel')).toBeVisible();
},
);
it.each([
{ status: 'error', notified: false },
{ status: 'canceled', notified: false },
{ status: 'success', notified: false },
])('keeps setup available after $status, notified: $notified', async (result) => {
const view = await completeSetup();
actionsMock.executeWorkflow.mockResolvedValueOnce({
workflowId: 'wf1',
executionId: 'execution-1',
...result,
});
await userEvent.click(view.getByRole('button', { name: 'Execute' }));
await flushPromises();
expect(view.getByRole('button', { name: 'Execute' })).toBeEnabled();
});
it('allows retry when no execution was started', async () => {
const view = await completeSetup();
actionsMock.executeWorkflow.mockResolvedValueOnce(undefined);
await userEvent.click(view.getByRole('button', { name: 'Execute' }));
expect(view.getByRole('button', { name: 'Execute' })).toBeEnabled();
});
it('keeps new requirements visible after a successful execution', async () => {
const view = await completeSetup();
actionsMock.executeWorkflow.mockImplementationOnce(async () => {
stateMock.rows.push({ item: parametersItem, isDone: false });
return { workflowId: 'wf1', executionId: 'execution-1', status: 'success', notified: true };
});
await userEvent.click(view.getByRole('button', { name: 'Execute' }));
await flushPromises();
expect(view.getByRole('button', { name: /Details/ })).toBeVisible();
expect(view.queryByRole('button', { name: 'Execute' })).toBeNull();
});
it('keeps the panel visible when a new build starts before the execution notification returns', async () => {
const view = await completeSetup();
actionsMock.executeWorkflow.mockImplementationOnce(async () => {
stateMock.isAgentBuilding = true;
return { workflowId: 'wf1', executionId: 'execution-1', status: 'success', notified: true };
});
await fireEvent.click(view.getByRole('button', { name: 'Execute' }));
await flushPromises();
expect(view.getByTestId('instance-ai-setup-panel')).toBeVisible();
});
it('renders nothing when there are no rows', () => {
@ -126,40 +522,184 @@ describe('InstanceAiSetupPanel', () => {
expect(queryByTestId('instance-ai-setup-panel')).toBeNull();
});
it('renders one row per item with name and done status', () => {
it('hides pre-existing connections and keeps unrelated parameters in Details', () => {
stateMock.rows = [
{ item: credentialItem, isDone: true },
{ item: parametersItem, isDone: false },
];
const { getByTestId, getAllByTestId, getByText } = renderComponent();
expect(getByTestId('instance-ai-setup-panel')).toBeInTheDocument();
expect(getAllByTestId('instance-ai-setup-panel-row')).toHaveLength(2);
// App name resolves through the credential type's display name.
expect(getByText('Notion')).toBeInTheDocument();
expect(getByText('Send Slack')).toBeInTheDocument();
const doneStatuses = getAllByTestId('instance-ai-setup-panel-row-done');
expect(doneStatuses).toHaveLength(1);
expect(doneStatuses[0]).toHaveTextContent('instanceAi.setupPanel.connected');
const { getAllByTestId, queryByText, getByText } = renderComponent();
expect(getAllByTestId('setup-panel-row')).toHaveLength(1);
expect(queryByText('Notion')).toBeNull();
expect(getByText('Details')).toBeVisible();
});
it('drills into a row and back without losing the list', async () => {
it('waits for fresh credentials before recording pending visibility', async () => {
stateMock.credentialsAvailable = false;
stateMock.rows = [{ item: credentialItem, isDone: false }];
const { queryByTestId } = renderComponent();
expect(queryByTestId('instance-ai-setup-panel')).toBeNull();
stateMock.rows = [{ item: credentialItem, isDone: true }];
stateMock.credentialsAvailable = true;
await Promise.resolve();
expect(queryByTestId('instance-ai-setup-panel')).toBeNull();
});
it('keeps a newly completed service visible after remount and removes obsolete requirements', async () => {
stateMock.rows = [{ item: credentialItem, isDone: false }];
const first = renderComponent();
expect(first.getByRole('button', { name: /Notion/ })).toBeVisible();
stateMock.rows = [{ item: credentialItem, isDone: true }];
await Promise.resolve();
first.unmount();
const second = renderComponent();
await userEvent.click(second.getByRole('button', { name: 'Setup complete' }));
expect(second.getByRole('button', { name: 'Notion Complete' })).toBeVisible();
stateMock.rows = [{ item: parametersItem, isDone: false }];
stateMock.nodesByName = { 'Send Slack': slackNode };
await Promise.resolve();
expect(second.queryByText('Notion')).toBeNull();
expect(second.getByText('Details')).toBeVisible();
});
const { getByTestId, getAllByTestId, queryByTestId, queryAllByTestId } = renderComponent();
it('can open an early credential without a node once its project and type are ready', async () => {
stateMock.rows = [{ item: { ...credentialItem, nodeBindings: [] }, isDone: false }];
const { getByRole, getByTestId } = renderComponent();
await fireEvent.click(getByRole('button', { name: /Notion/ }));
expect(getByTestId('setup-panel-back')).toBeVisible();
});
await fireEvent.click(getAllByTestId('instance-ai-setup-panel-row')[0]);
it('connects managed OAuth directly from the row and keeps cancellation pending', async () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
stateMock.rows = [{ item: credentialItem, isDone: false }];
oauthMock.createAndAuthorize
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'cred-1' });
credentialsMock.getCredentialById.mockReturnValue({ id: 'cred-1', name: 'Account' });
actionsMock.bindCredential.mockResolvedValue('applied');
const { getByRole, queryByRole } = renderComponent();
await userEvent.click(getByRole('button', { name: 'Connect' }));
expect(oauthMock.createAndAuthorize).toHaveBeenCalledWith('notionApi', undefined, {
projectId: 'p1',
workflowId: 'wf1',
});
expect(actionsMock.bindCredential).not.toHaveBeenCalled();
expect(queryByRole('dialog')).toBeNull();
await userEvent.click(getByRole('button', { name: 'Connect' }));
await waitFor(() =>
expect(actionsMock.bindCredential).toHaveBeenCalledWith(credentialItem, {
id: 'cred-1',
name: 'Account',
}),
);
});
expect(getByTestId('instance-ai-setup-panel-back')).toBeInTheDocument();
expect(queryAllByTestId('instance-ai-setup-panel-row')).toHaveLength(0);
it('opens the credential picker instead of a direct OAuth action when an account is available', async () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
credentialsMock.getUsableCredentialByType.mockReturnValue([{ id: 'cred-1', name: 'Account' }]);
stateMock.rows = [{ item: credentialItem, isDone: false }];
const { getByRole, queryByRole } = renderComponent();
expect(queryByRole('button', { name: 'Connect' })).toBeNull();
await userEvent.click(getByRole('button', { name: /Notion/ }));
expect(getByRole('dialog', { name: 'Notion' })).toBeVisible();
expect(oauthMock.createAndAuthorize).not.toHaveBeenCalled();
});
await fireEvent.click(getByTestId('instance-ai-setup-panel-back'));
it.each([
{ parameters: 'none', result: 'applied', draft: false, closes: true },
{ parameters: 'none', result: 'queued', draft: false, closes: true },
{ parameters: 'pending', result: 'applied', draft: false, closes: false },
{ parameters: 'done', result: 'applied', draft: false, closes: true },
{ parameters: 'none', result: 'error', draft: false, closes: false },
{ parameters: 'none', result: 'applied', draft: true, closes: false },
])(
'returns from OAuth setup only when ready: $parameters, $result, draft=$draft',
async ({ parameters, result, draft, closes }) => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
stateMock.rows = [
{ item: credentialItem, isDone: false },
{ item: parametersItem, isDone: false },
];
if (parameters !== 'none')
stateMock.rows.push({
item: {
id: 'wf1:parameters:Notion',
kind: 'parameters',
nodeName: 'Notion',
parameterNames: ['channel'],
},
isDone: parameters === 'done',
});
stateMock.nodesByName = { Notion: { ...slackNode, name: 'Notion' }, 'Send Slack': slackNode };
credentialsMock.getCredentialById.mockReturnValue({ id: 'cred-1', name: 'Account' });
actionsMock.bindCredential.mockImplementationOnce(async () => {
if (result !== 'error') stateMock.rows[0].isDone = true;
return result;
});
const view = renderComponent({
global: {
stubs: {
InstanceAiSetupPanelDetail: true,
CredentialIcon: true,
NodeIcon: true,
InstanceAiSetupCredential: defineComponent({
emits: ['bindCredential', 'update:hasChanges'],
setup(_, { emit }) {
return () =>
h(
'button',
{
onClick: () => {
emit('update:hasChanges', draft);
emit('bindCredential', credentialItem, 'cred-1');
},
},
'Finish OAuth',
);
},
}),
},
},
});
await userEvent.click(view.getByRole('button', { name: /Notion/ }));
await userEvent.click(view.getByRole('button', { name: 'Finish OAuth' }));
await flushPromises();
if (closes) {
expect(view.queryByRole('dialog')).toBeNull();
expect(view.getByRole('button', { name: 'Notion Complete' })).toBeVisible();
} else {
expect(view.getByRole('dialog', { name: 'Notion' })).toBeVisible();
}
},
);
expect(queryByTestId('instance-ai-setup-panel-back')).toBeNull();
expect(getAllByTestId('instance-ai-setup-panel-row')).toHaveLength(1);
it('shows only Connect on the managed OAuth row', () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
stateMock.rows = [{ item: credentialItem, isDone: false }];
const { getByRole, queryByRole } = renderComponent();
expect(getByRole('button', { name: 'Connect' })).toBeVisible();
expect(queryByRole('button', { name: 'More options' })).not.toBeInTheDocument();
});
it('does not bind a pending row connection after switching workflows', async () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
stateMock.rows = [{ item: credentialItem, isDone: false }];
let finish: (value: { id: string }) => void = () => {};
oauthMock.createAndAuthorize.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const { getByRole, rerender } = renderComponent();
await userEvent.click(getByRole('button', { name: 'Connect' }));
await rerender({ workflowId: 'wf2' });
finish({ id: 'cred-1' });
await flushPromises();
expect(actionsMock.bindCredential).not.toHaveBeenCalled();
expect(oauthMock.cancelAuthorize).toHaveBeenCalled();
});
it('does not open a detail while the item has no resolvable node', async () => {
@ -167,11 +707,11 @@ describe('InstanceAiSetupPanel', () => {
const { getAllByTestId, queryByTestId } = renderComponent();
const row = getAllByTestId('instance-ai-setup-panel-row')[0];
const row = getAllByTestId('setup-panel-row')[0];
expect(row).toBeDisabled();
await fireEvent.click(row);
expect(queryByTestId('instance-ai-setup-panel-back')).toBeNull();
expect(queryByTestId('setup-panel-back')).toBeNull();
});
});

View File

@ -81,7 +81,8 @@ export function buildInstanceAiArtifactCredentialQuestion(
if (credential.placeholderTitles?.length) {
return `${templatedValuesQuestion(credential)}${node}`;
}
return `How do I set up the credentials for ${credential.displayName}?${node}${existingCredentialNote(credential)}`;
const setupContext = credential.setupContext ? ` ${credential.setupContext}` : '';
return `How do I set up the credentials for ${credential.displayName}?${node}${existingCredentialNote(credential)}${setupContext}`;
}
const pendingFirstMessageKey = (threadId: string) => `n8n-instance-ai-first-message:${threadId}`;

View File

@ -1,22 +1,18 @@
import { computed, shallowReactive, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { computed, ref, shallowReactive, toValue, watch, type MaybeRefOrGetter } from 'vue';
import isEqual from 'lodash/isEqual';
import type {
InstanceAiAttachment,
InstanceAiHandoffContext,
InstanceAiSetupItem,
} from '@n8n/api-types';
import type { InstanceAiMessageAuthorship } from '../prefills';
import { useI18n } from '@n8n/i18n';
import type { InstanceAiSetupItem } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
import { NodeHelpers } from 'n8n-workflow';
import type { INodeParameters } from 'n8n-workflow';
import type { INodeCredentialsDetails, INodeParameters } from 'n8n-workflow';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { getWorkflow } from '@/app/api/workflows';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { getNodeCredentialTypes } from '@/features/setupPanel/setupPanel.utils';
import { GENERIC_AUTH_CREDENTIAL_TYPES } from '@n8n/api-types';
import {
createWorkflowDocumentId,
useExistingWorkflowDocumentStore,
@ -32,10 +28,7 @@ import {
export type SetupCredentialItem = Extract<InstanceAiSetupItem, { kind: 'credential' }>;
export interface SetupCredentialRef {
id: string;
name: string;
}
export type SetupCredentialRef = INodeCredentialsDetails;
export type SetupPanelApplyResult =
/** The workflow PATCH landed. */
@ -53,22 +46,6 @@ export type SetupPanelApplyResult =
/** Agent lock held — stashed, flushed when the build settles. */
| 'queued';
/**
* Thread surface the apply paths need. Structurally satisfied by
* `useThread()`; kept narrow so tests can pass a plain stub.
*/
export interface SetupPanelThreadActions {
sendMessage: (
message: string,
opts: {
authorship: InstanceAiMessageAuthorship;
attachments?: InstanceAiAttachment[];
pushRef?: string;
handoffContext?: InstanceAiHandoffContext;
},
) => Promise<boolean>;
}
interface CredentialBind {
item: SetupCredentialItem;
credential: SetupCredentialRef;
@ -79,6 +56,12 @@ interface ParameterApply {
changes: SetupParameterChange[];
}
export interface SetupParameterSubmission {
nodeName: string;
values: INodeParameters;
baseline: INodeParameters;
}
interface NodesDelta {
credentialBinds: CredentialBind[];
parameterApplies: ParameterApply[];
@ -108,7 +91,13 @@ function applyDeltaToNodes(nodes: INodeUi[], delta: NodesDelta): 'changed' | 'no
const current = node.credentials?.[item.credentialType];
// Legacy workflow JSON may carry a plain credential name; the bind
// overwrites it with a proper { id, name } reference.
if (typeof current !== 'string' && current?.id === credential.id) continue;
if (
typeof current !== 'string' &&
current?.id === credential.id &&
(current?.__aiGatewayManaged === true) === (credential.__aiGatewayManaged === true)
) {
continue;
}
node.credentials = { ...node.credentials, [item.credentialType]: { ...credential } };
changed = true;
}
@ -132,8 +121,7 @@ function applyDeltaToNodes(nodes: INodeUi[], delta: NodesDelta): 'changed' | 'no
/**
* Setup panel apply paths (T6 of setup panel v2): bind credentials and submit
* parameter values through the normal versionId/checksum-guarded workflow
* PATCH, and send the synthesized Execute message through the normal chat
* send endpoint.
* PATCH.
*
* The agent lock rule: no user-initiated workflow write while the agent is
* editing. Writes requested mid-build queue up (latest wins per item/node)
@ -141,7 +129,6 @@ function applyDeltaToNodes(nodes: INodeUi[], delta: NodesDelta): 'changed' | 'no
* dropped and done-ness re-derives from the saved workflow.
*/
export function useSetupPanelActions(options: {
thread: SetupPanelThreadActions;
/** The thread's active artifact workflow — same source as `useSetupPanelState`. */
workflowId: MaybeRefOrGetter<string | undefined>;
isAgentBuilding: MaybeRefOrGetter<boolean>;
@ -151,16 +138,18 @@ export function useSetupPanelActions(options: {
* caller to return to. Manual `flushPendingApplies` calls report through
* their return value instead.
*/
onFlushResult?: (result: SetupPanelApplyResult) => void;
onFlushResult?: (result: SetupPanelApplyResult, workflowId: string) => void;
}) {
const i18n = useI18n();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const activeApplyCount = ref(0);
const isApplying = computed(() => activeApplyCount.value > 0);
const pendingCredentialBinds = shallowReactive(new Map<string, CredentialBind>());
const pendingParameterApplies = shallowReactive(new Map<string, SetupParameterChange[]>());
const applyingDeltas = shallowReactive(new Map<NodesDelta, string>());
/**
* The workflow the queued writes were captured for. A build settling and a
* re-anchor can land in the same flush (the settle watcher runs first), so
@ -173,6 +162,35 @@ export function useSetupPanelActions(options: {
() => pendingCredentialBinds.size + pendingParameterApplies.size,
);
function getPendingCredential(itemId: string, nodeName?: string): SetupCredentialRef | undefined {
const workflowId = toValue(options.workflowId);
const scopedId = nodeName ? `${itemId}:${nodeName}` : itemId;
const queued = pendingCredentialBinds.get(scopedId) ?? pendingCredentialBinds.get(itemId);
if (queuedWorkflowId === workflowId && queued) return queued.credential;
for (const [delta, targetId] of [...applyingDeltas].reverse()) {
if (targetId !== workflowId) continue;
const bind =
delta.credentialBinds.find(({ item }) => item.id === scopedId) ??
delta.credentialBinds.find(({ item }) => item.id === itemId);
if (bind) return bind.credential;
}
return undefined;
}
function getPendingParameterChanges(nodeName: string): SetupParameterChange[] {
const workflowId = toValue(options.workflowId);
const queued = pendingParameterApplies.get(nodeName) ?? [];
let changes: SetupParameterChange[] = [];
for (const [delta, targetId] of applyingDeltas) {
if (targetId !== workflowId) continue;
for (const apply of delta.parameterApplies) {
if (apply.nodeName === nodeName)
changes = mergeSetupParameterChanges(changes, apply.changes);
}
}
return mergeSetupParameterChanges(changes, queuedWorkflowId === workflowId ? queued : []);
}
/** Puts a delta back into the queues, keeping any newer entries queued meanwhile. */
function requeueDelta(workflowId: string, delta: NodesDelta) {
if (toValue(options.workflowId) !== workflowId) return;
@ -304,54 +322,94 @@ export function useSetupPanelActions(options: {
workflowId: string,
delta: NodesDelta,
): Promise<SetupPanelApplyResult> {
for (let attempt = 0; attempt < 2; attempt++) {
let fresh: IWorkflowDb;
try {
fresh = await getWorkflow(rootStore.restApiContext, workflowId);
} catch {
return 'error';
}
// Never write unguarded: without the checksum the backend skips
// conflict detection and the PATCH could clobber a concurrent edit.
if (!fresh.checksum) return 'error';
activeApplyCount.value++;
applyingDeltas.set(delta, workflowId);
try {
for (let attempt = 0; attempt < 2; attempt++) {
let fresh: IWorkflowDb;
try {
fresh = await getWorkflow(rootStore.restApiContext, workflowId);
} catch {
return 'error';
}
// Never write unguarded: without the checksum the backend skips
// conflict detection and the PATCH could clobber a concurrent edit.
if (!fresh.checksum) return 'error';
const nodes = fresh.nodes;
// applyDeltaToNodes mutates these nodes — snapshot the pre-PATCH
// values first so the mirror can spot newer local edits.
const baseline: NodesBaseline = new Map(
nodes.map((node) => [
node.name,
{ credentials: { ...node.credentials }, parameters: { ...node.parameters } },
]),
);
const outcome = applyDeltaToNodes(nodes, delta);
if (outcome !== 'changed') return outcome;
const nodes = fresh.nodes;
// An early announcement can arrive before its workflow nodes exist.
if (delta.credentialBinds.some((bind) => !bind.item.nodeBindings?.length)) {
try {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
} catch {
return 'error';
}
}
const resolvedDelta: NodesDelta = {
...delta,
credentialBinds: delta.credentialBinds.map((bind) => {
if (
bind.item.nodeBindings?.length ||
GENERIC_AUTH_CREDENTIAL_TYPES.has(bind.item.credentialType)
)
return bind;
return {
...bind,
item: {
...bind.item,
nodeBindings: nodes
.filter(
(node) =>
!node.disabled &&
getNodeCredentialTypes(nodeTypesStore, node).includes(
bind.item.credentialType,
),
)
.map((node) => ({ nodeName: node.name })),
},
};
}),
};
// applyDeltaToNodes mutates these nodes — snapshot the pre-PATCH
// values first so the mirror can spot newer local edits.
const baseline: NodesBaseline = new Map(
nodes.map((node) => [
node.name,
{ credentials: { ...node.credentials }, parameters: { ...node.parameters } },
]),
);
const outcome = applyDeltaToNodes(nodes, resolvedDelta);
if (outcome !== 'changed') return outcome;
// The anchor and the agent lock can both move while the fetch was
// awaited. A re-anchored panel no longer owns this write — drop it
// (the same rule the queue watcher enforces). A re-acquired lock (a
// new build starting is also what a 409 usually means) requeues it
// instead: a user write must not land mid-build.
if (toValue(options.workflowId) !== workflowId) return 'dropped';
if (toValue(options.isAgentBuilding)) {
requeueDelta(workflowId, delta);
return 'queued';
}
// The anchor and the agent lock can both move while the fetch was
// awaited. A re-anchored panel no longer owns this write — drop it
// (the same rule the queue watcher enforces). A re-acquired lock (a
// new build starting is also what a 409 usually means) requeues it
// instead: a user write must not land mid-build.
if (toValue(options.workflowId) !== workflowId) return 'dropped';
if (toValue(options.isAgentBuilding)) {
requeueDelta(workflowId, delta);
return 'queued';
}
try {
const updated = await workflowsStore.updateWorkflow(workflowId, {
nodes,
versionId: fresh.versionId,
expectedChecksum: fresh.checksum,
});
syncHydratedDocument(workflowId, delta, baseline, updated);
return 'applied';
} catch (error) {
const isConflict = error instanceof ResponseError && error.httpStatusCode === 409;
if (!isConflict) return 'error';
try {
const updated = await workflowsStore.updateWorkflow(workflowId, {
nodes,
versionId: fresh.versionId,
expectedChecksum: fresh.checksum,
});
syncHydratedDocument(workflowId, resolvedDelta, baseline, updated);
return 'applied';
} catch (error) {
const isConflict = error instanceof ResponseError && error.httpStatusCode === 409;
if (!isConflict) return 'error';
}
}
return 'conflict';
} finally {
applyingDeltas.delete(delta);
activeApplyCount.value--;
}
return 'conflict';
}
/**
@ -383,18 +441,30 @@ export function useSetupPanelActions(options: {
values: INodeParameters,
baseline: INodeParameters = {},
): Promise<SetupPanelApplyResult> {
const changes = getSetupParameterChanges(baseline, { ...baseline, ...values });
return await applyParameterBatch([{ nodeName, values, baseline }]);
}
/** Confirm all visible node fields in one guarded workflow update. */
async function applyParameterBatch(
submissions: SetupParameterSubmission[],
): Promise<SetupPanelApplyResult> {
const parameterApplies = submissions.map(({ nodeName, values, baseline }) => ({
nodeName,
changes: getSetupParameterChanges(baseline, { ...baseline, ...values }),
}));
if (toValue(options.isAgentBuilding)) {
queuedWorkflowId = toValue(options.workflowId);
const existing = pendingParameterApplies.get(nodeName) ?? [];
pendingParameterApplies.set(nodeName, mergeSetupParameterChanges(existing, changes));
for (const { nodeName, changes } of parameterApplies) {
const existing = pendingParameterApplies.get(nodeName) ?? [];
pendingParameterApplies.set(nodeName, mergeSetupParameterChanges(existing, changes));
}
return 'queued';
}
const workflowId = toValue(options.workflowId);
if (!workflowId) return 'error';
return await patchWorkflowNodes(workflowId, {
credentialBinds: [],
parameterApplies: [{ nodeName, changes }],
parameterApplies,
});
}
@ -430,9 +500,10 @@ export function useSetupPanelActions(options: {
watch(
() => toValue(options.isAgentBuilding),
(building, wasBuilding) => {
if (wasBuilding && !building) {
const workflowId = toValue(options.workflowId);
if (wasBuilding && !building && workflowId) {
void flushPendingApplies().then((result) => {
if (result) options.onFlushResult?.(result);
if (result) options.onFlushResult?.(result, workflowId);
});
}
},
@ -448,27 +519,14 @@ export function useSetupPanelActions(options: {
},
);
/**
* Sends the synthesized Execute message through the normal send endpoint.
* The handoff context routes it to the agent's test execution once the
* agent lane consumes it; until then the message text alone instructs the
* agent.
*/
async function executeWorkflow(): Promise<boolean> {
const workflowId = toValue(options.workflowId);
if (!workflowId) return false;
return await options.thread.sendMessage(i18n.baseText('instanceAi.setupPanel.executeMessage'), {
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: rootStore.pushRef,
handoffContext: { source: 'setup-panel-execute', workflowId },
});
}
return {
bindCredential,
applyParameterValues,
executeWorkflow,
applyParameterBatch,
flushPendingApplies,
pendingApplyCount,
getPendingCredential,
getPendingParameterChanges,
isApplying,
};
}

View File

@ -0,0 +1,52 @@
import { computed, onScopeDispose, provide, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { deepCopy } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { ExpressionLocalResolveContextSymbol, WorkflowDocumentStoreKey } from '@/app/constants';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import {
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { disposeNDVStore, useNDVStore } from '@/features/ndv/shared/ndv.store';
import {
disposeWorkflowExecutionStateStore,
useWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
/** Give existing credential and parameter controls a disposable local document. */
export function useSetupPanelDocument(options: {
workflowId: MaybeRefOrGetter<string | undefined>;
itemId: MaybeRefOrGetter<string>;
node: MaybeRefOrGetter<INodeUi | undefined>;
}) {
const documentId = computed(() =>
createWorkflowDocumentId(
toValue(options.workflowId) ?? 'instance-ai-setup-panel',
toValue(options.itemId),
),
);
const document = computed(() => useWorkflowDocumentStore(documentId.value));
watch(
[documentId, () => toValue(options.node)],
// setNodes normalizes node positions. Keep those mutations local to the form.
([_id, node]) => document.value.setNodes(node ? [deepCopy(node)] : []),
{ immediate: true, deep: true },
);
function dispose(id: WorkflowDocumentId) {
disposeNDVStore(useNDVStore(id));
disposeWorkflowExecutionStateStore(useWorkflowExecutionStateStore(id));
disposeWorkflowDocumentStore(useWorkflowDocumentStore(id));
}
watch(documentId, (_id, previous) => dispose(previous));
onScopeDispose(() => dispose(documentId.value));
const expressionContext = computed<ExpressionLocalResolveContext | undefined>(() => {
const node = toValue(options.node);
return node ? { localResolve: true, nodeName: node.name, additionalKeys: {} } : undefined;
});
provide(ExpressionLocalResolveContextSymbol, expressionContext);
provide(WorkflowDocumentStoreKey, document);
}

View File

@ -0,0 +1,238 @@
import {
computed,
getCurrentScope,
onScopeDispose,
reactive,
toValue,
watch,
type MaybeRefOrGetter,
} from 'vue';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { isTerminalExecutionStatus, type TerminalExecutionStatus } from 'n8n-workflow';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { getWorkflow } from '@/app/api/workflows';
import { useRunWorkflowApi } from '@/app/composables/useRunWorkflowApi';
import {
createWorkflowDocumentId,
useExistingWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useLogsStore } from '@/app/stores/logs.store';
import { isChatNode } from '@/app/utils/aiUtils';
import type { ThreadRuntime } from '../instanceAi.store';
import { getExecutionResultsByWorkflow } from '../canvasPreview.utils';
export interface SetupPanelExecutionResult {
workflowId: string;
executionId: string;
status: TerminalExecutionStatus;
notified: boolean;
}
export function useSetupPanelExecution(options: {
workflowId: MaybeRefOrGetter<string | undefined>;
thread: Pick<ThreadRuntime, 'id' | 'messages' | 'sendMessage' | 'rememberManualExecution'>;
}) {
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const { runWorkflowApi } = useRunWorkflowApi();
const nodeTypesStore = useNodeTypesStore();
const pushStore = usePushConnectionStore();
const logsStore = useLogsStore();
const telemetry = useTelemetry();
const toast = useToast();
const i18n = useI18n();
const runningWorkflows = reactive(new Set<string>());
const isRunning = computed(() => runningWorkflows.has(toValue(options.workflowId) ?? ''));
let disposed = false;
const cancelWaits = new Map<string, () => void>();
if (getCurrentScope())
onScopeDispose(() => {
disposed = true;
for (const cancelWait of cancelWaits.values()) cancelWait();
});
async function executeWorkflow(): Promise<SetupPanelExecutionResult | undefined> {
const workflowId = toValue(options.workflowId);
if (!workflowId || runningWorkflows.has(workflowId) || disposed) return;
if (!pushStore.isConnected)
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
const executionState = useWorkflowExecutionStateStore(createWorkflowDocumentId(workflowId));
if (executionState.isWorkflowRunning) return;
runningWorkflows.add(workflowId);
let cleanup = () => {};
try {
const workflow = await getWorkflow(rootStore.restApiContext, workflowId);
await nodeTypesStore.loadNodeTypesIfNotLoaded();
if (
disposed ||
toValue(options.workflowId) !== workflowId ||
executionState.isWorkflowRunning
)
return;
const triggers = workflow.nodes.filter(
(node) => !node.disabled && nodeTypesStore.isTriggerNode(node.type),
);
const trigger =
triggers.find((node) => node.name === executionState.selectedTriggerNodeName) ??
triggers[0];
if (!trigger)
throw new Error(i18n.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting'));
logsStore.toggleOpen(true);
if (isChatNode(trigger)) {
toast.showMessage({
title: i18n.baseText('aiAssistant.builder.toast.title'),
message: i18n.baseText('aiAssistant.builder.toast.description'),
type: 'info',
});
return;
}
let agentExecutionId: string | undefined;
for (const message of options.thread.messages) {
if (message.agentTree)
agentExecutionId =
getExecutionResultsByWorkflow(message.agentTree).get(workflowId)?.executionId ??
agentExecutionId;
}
const completed = createDeferredPromise<
Omit<SetupPanelExecutionResult, 'notified'> | undefined
>();
const finished = new Map<string, TerminalExecutionStatus>();
let executionId: string | undefined;
let startedId: string | undefined;
let waitingForWebhook = false;
let settled = false;
const finish = (id: string, status: TerminalExecutionStatus) => {
if (settled) return;
settled = true;
completed.resolve({ workflowId, executionId: id, status });
};
const observeId = (id: string) => {
executionId = id;
if (executionState.activeExecutionId === null) executionState.setActiveExecutionId(id);
options.thread.rememberManualExecution(workflowId, id, agentExecutionId);
const status = finished.get(id);
if (status) finish(id, status);
};
const readStatus = async () => {
if (!executionId || settled || disposed) return;
try {
const execution = await workflowsStore.fetchExecutionDataById(executionId);
if (disposed || execution?.workflowId !== workflowId || execution.id !== executionId)
return;
if (execution && isTerminalExecutionStatus(execution.status))
finish(execution.id, execution.status);
} catch {
/* Completion can still arrive on the push connection. */
}
};
// Subscribe before starting: short executions can finish before the POST returns.
const removeListener = pushStore.addEventListener((event) => {
if (
event.type === 'testWebhookDeleted' &&
event.data.workflowId === workflowId &&
!event.data.executionId &&
waitingForWebhook &&
!executionId
) {
completed.resolve(undefined);
return;
}
if (event.type === 'testWebhookReceived' && event.data.workflowId === workflowId) {
startedId = event.data.executionId;
if (waitingForWebhook && !executionId) observeId(startedId);
}
if (
event.type === 'executionStarted' &&
event.data.workflowId === workflowId &&
event.data.source !== 'instance_ai'
) {
startedId = event.data.executionId;
if (waitingForWebhook && !executionId) observeId(startedId);
}
if (
event.type !== 'executionFinished' ||
event.data.workflowId !== workflowId ||
!isTerminalExecutionStatus(event.data.status)
)
return;
finished.set(event.data.executionId, event.data.status);
if (event.data.executionId === executionId) finish(executionId, event.data.status);
});
const stopReconnectWatch = watch(
() => pushStore.isConnected,
(connected) => {
if (connected) void readStatus();
},
);
cleanup = () => {
removeListener();
stopReconnectWatch();
};
cancelWaits.set(workflowId, () => {
cleanup();
completed.resolve(undefined);
});
telemetry.track(TELEMETRY_EVENT.WORKFLOW.USER_REQUESTED_WORKFLOW_TEST, {
source: 'instance_ai_setup_panel',
workflow_id: workflowId,
thread_id: options.thread.id,
});
const response = await runWorkflowApi(
{ workflowId, triggerToStartFrom: { name: trigger.name } },
executionState.documentId,
);
if (disposed) {
if (executionState.activeExecutionId === null) {
executionState.setActiveExecutionId(undefined);
executionState.setExecutionWaitingForWebhook(false);
}
return;
}
waitingForWebhook = response.waitingForWebhook === true;
executionState.setExecutionWaitingForWebhook(waitingForWebhook);
if (response.executionId) observeId(response.executionId);
else if (waitingForWebhook && startedId) observeId(startedId);
else if (!waitingForWebhook)
throw new Error(i18n.baseText('instanceAi.setupPanel.executeError'));
void readStatus();
const result = await completed.promise;
cleanup();
// A mounted canvas handles terminal data itself. Without it, release this run here.
if (
(!result && executionState.activeExecutionId === null) ||
(result &&
!useExistingWorkflowDocumentStore(executionState.documentId)?.hydrated &&
executionState.activeExecutionId === result.executionId)
)
executionState.setActiveExecutionId(undefined);
executionState.setExecutionWaitingForWebhook(false);
if (disposed) return;
if (!result) return;
const notified = await options.thread.sendMessage(
i18n.baseText('instanceAi.setupPanel.executedMessage', {
interpolate: { executionId: result.executionId },
}),
{
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: rootStore.pushRef,
},
);
return { ...result, notified };
} finally {
cleanup();
cancelWaits.delete(workflowId);
runningWorkflows.delete(workflowId);
}
}
return { executeWorkflow, isRunning };
}

View File

@ -1,4 +1,4 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { computed, shallowReactive, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { InstanceAiAgentNode, InstanceAiSetupItem } from '@n8n/api-types';
import { useWorkflowSetupItems } from '@/features/setupPanel/composables/useWorkflowSetupItems';
@ -77,6 +77,32 @@ export function useSetupPanelState(options: {
if (!id || !Object.hasOwn(thread.setupItemsByWorkflowId, id)) return [];
return thread.setupItemsByWorkflowId[id];
});
const credentialContext = shallowReactive(new Map<string, InstanceAiSetupItem>());
watch(
[() => toValue(options.workflowId), eventItems],
([id, items], [previousId]) => {
if (id !== previousId) credentialContext.clear();
// Later snapshots can omit the recipe while the workflow still needs the credential.
for (const item of items) {
if (item.kind === 'credential') {
credentialContext.set(
item.id,
completeCredentialContext(item, credentialContext.get(item.id)),
);
}
}
},
{ immediate: true, flush: 'sync' },
);
watch(
[eventItems, isAgentBuilding],
([items, building]) => {
// The SDK saves resolved credentials before announcing setup, while its tool is still active.
if (building && items.length > 0) void derivation.refreshWorkflow({ force: true });
},
{ immediate: true, flush: 'sync' },
);
/**
* Reconciliation: while the agent edits the workflow, its events are the
@ -96,13 +122,19 @@ export function useSetupPanelState(options: {
const derivedById = new Map(
derivation.derivedCredentialItems.value.map((item) => [item.id, item]),
);
return eventItems.value.map((event) => {
const item = completeCredentialContext(event, derivedById.get(event.id));
const announcedIds = new Set(eventItems.value.map((item) => item.id));
const stillRequired = derivation.derivedCredentialItems.value.filter(
(item) => credentialContext.has(item.id) && !announcedIds.has(item.id),
);
return [...eventItems.value, ...stillRequired].map((event) => {
const item = completeCredentialContext(
completeCredentialContext(event, derivedById.get(event.id)),
credentialContext.get(event.id),
);
return { item, isDone: derivation.isItemDone(item) };
});
}
const derived = derivation.derivedItems.value;
const eventsById = new Map(eventItems.value.map((item) => [item.id, item]));
const derivedIds = new Set(derived.map((item) => item.id));
// Parameter rows the agent announced that settled before this session's
// derivation ever saw them raise issues (e.g. resolved mid-build, then a
@ -115,17 +147,21 @@ export function useSetupPanelState(options: {
item.kind === 'parameters' && !derivedIds.has(item.id) && derivation.isItemDone(item),
);
return [...derived, ...settledEventItems].map((derivedItem) => {
const item = completeCredentialContext(derivedItem, eventsById.get(derivedItem.id));
const item = completeCredentialContext(derivedItem, credentialContext.get(derivedItem.id));
return { item, isDone: derivation.isItemDone(item) };
});
});
return {
credentialsAvailable: derivation.credentialsAvailable,
isRefreshingWorkflow: derivation.isRefreshingWorkflow,
rows,
rowSource,
isAgentBuilding,
getNodeByName: derivation.getNodeByName,
workflowProjectId: derivation.workflowProjectId,
refreshWorkflow: derivation.refreshWorkflow,
isItemDone: derivation.isItemDone,
isCredentialConfigured: derivation.isCredentialConfigured,
};
}

View File

@ -0,0 +1,163 @@
import { onScopeDispose, ref, shallowReactive, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { TELEMETRY_EVENT, type InferTelemetryProps } from '@n8n/telemetry';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import type { SetupPanelGroup } from '../setupPanelGroups';
import type { SetupPanelRow } from './useSetupPanelState';
import type { SetupCredentialItem, SetupPanelApplyResult } from './useSetupPanelActions';
export type SetupPanelConnectionMethod = InferTelemetryProps<
typeof TELEMETRY_EVENT.CREDENTIALS.USER_STARTED_CREDENTIAL_CONNECTION
>['method'];
type DismissReason = InferTelemetryProps<
typeof TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_DISMISSED
>['reason'];
function createTelemetryState() {
return {
owners: shallowReactive(new Set<symbol>()),
shownRows: new Set<string>(),
snapshots: new Map<string, string>(),
connectionAttempts: new Map<
string,
{ method: SetupPanelConnectionMethod; workflowId: string }
>(),
visibleWorkflowId: ref<string>(),
};
}
// A thread can briefly have two mounted views during a layout change.
const states = new WeakMap<object, ReturnType<typeof createTelemetryState>>();
export function useSetupPanelTelemetry(options: {
workflowId: MaybeRefOrGetter<string>;
thread: { id: string };
rows: MaybeRefOrGetter<SetupPanelRow[]>;
groups: MaybeRefOrGetter<SetupPanelGroup[]>;
shownItemIds: MaybeRefOrGetter<string[]>;
ready: MaybeRefOrGetter<boolean>;
}) {
const telemetry = useTelemetry();
const state = states.get(options.thread) ?? createTelemetryState();
states.set(options.thread, state);
const owner = Symbol();
state.owners.add(owner);
const isOwner = () => [...state.owners].at(-1) === owner;
const { shownRows, snapshots, connectionAttempts } = state;
const context = () => ({
workflow_id: toValue(options.workflowId),
thread_id: options.thread.id,
});
function trackDismissed(reason: DismissReason) {
if (!state.visibleWorkflowId.value) return;
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_DISMISSED, {
workflow_id: state.visibleWorkflowId.value,
thread_id: options.thread.id,
reason,
});
state.visibleWorkflowId.value = undefined;
}
watch(
() =>
[
toValue(options.workflowId),
toValue(options.rows),
toValue(options.groups),
toValue(options.ready),
[...toValue(options.shownItemIds)],
isOwner(),
] as const,
([workflowId, rows, groups, ready, shownItemIds, ownsTracking]) => {
if (!ownsTracking) return;
if (state.visibleWorkflowId.value && state.visibleWorkflowId.value !== workflowId)
trackDismissed('navigation');
if (!ready) return;
const key = JSON.stringify(
rows
.map((row) => ({
id: row.item.id,
done: row.isDone,
shown: shownItemIds.includes(row.item.id),
parameters:
row.item.kind === 'parameters' ? [...row.item.parameterNames].sort() : undefined,
}))
.sort((a, b) => a.id.localeCompare(b.id)),
);
if (snapshots.get(workflowId) !== key) {
snapshots.set(workflowId, key);
const credentials = rows.filter((row) => row.item.kind === 'credential');
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_STATE_OBSERVED, {
...context(),
credential_count: credentials.length,
pending_credential_count: credentials.filter((row) => !row.isDone).length,
pending_parameter_count: rows.reduce(
(count, row) =>
count +
(row.item.kind === 'parameters' && !row.isDone ? row.item.parameterNames.length : 0),
0,
),
already_connected_count: credentials.filter(
(row) => row.isDone && !shownItemIds.includes(row.item.id),
).length,
});
}
if (groups.length) state.visibleWorkflowId.value = workflowId;
else trackDismissed('items_removed');
for (const group of groups) {
const rowKey = `${workflowId}:${group.id}`;
if (shownRows.has(rowKey)) continue;
shownRows.add(rowKey);
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.SETUP_PANEL_ITEM_SHOWN, {
...context(),
kind: group.credential ? 'credential' : group.node ? 'parameters' : 'details',
credential_type: group.credential?.item.credentialType,
parameter_count: group.parameters.reduce(
(count, row) => count + row.item.parameterNames.length,
0,
),
});
}
},
{ immediate: true },
);
function trackConnectionStarted(item: SetupCredentialItem, method: SetupPanelConnectionMethod) {
if (!isOwner()) return;
connectionAttempts.set(item.id, { method, workflowId: toValue(options.workflowId) });
telemetry.track(TELEMETRY_EVENT.CREDENTIALS.USER_STARTED_CREDENTIAL_CONNECTION, {
...context(),
source: 'instance_ai_setup_panel',
credential_type: item.credentialType,
method,
});
}
function trackConnectionCompleted(
item: SetupCredentialItem,
credentialId: string | null,
result: SetupPanelApplyResult,
) {
const attempt = connectionAttempts.get(item.id);
if (!attempt || (result !== 'applied' && result !== 'noop' && result !== 'queued')) return;
connectionAttempts.delete(item.id);
telemetry.track(TELEMETRY_EVENT.CREDENTIALS.USER_COMPLETED_CREDENTIAL_CONNECTION, {
workflow_id: attempt.workflowId,
thread_id: options.thread.id,
source: 'instance_ai_setup_panel',
credential_type: item.credentialType,
credential_id: credentialId,
method: attempt.method,
binding_state: result,
});
}
onScopeDispose(() => {
state.owners.delete(owner);
if (state.owners.size === 0) {
trackDismissed('navigation');
states.delete(options.thread);
}
});
return { trackConnectionStarted, trackConnectionCompleted, trackDismissed };
}

View File

@ -542,6 +542,21 @@ export function createThreadRuntime(
...findLatestSetupItemsFromMessages(messages.value),
...latestSetupItems.value,
}));
const latestSetupWorkflowId = computed(() => {
let latest: InstanceAiAgentNode['latestSetupAnnouncement'];
for (const message of messages.value) {
const announcement = message.agentTree?.latestSetupAnnouncement;
if (announcement && (!latest || announcement.timestamp >= latest.timestamp))
latest = announcement;
}
if (latest) return latest.workflowId;
// Legacy snapshots lack update order. Only select an unambiguous workflow.
for (const message of messages.value.toReversed()) {
const workflowIds = Object.keys(message.agentTree?.setupItemsByWorkflowId ?? {});
if (workflowIds.length > 0) return workflowIds.length === 1 ? workflowIds[0] : undefined;
}
return undefined;
});
// --- Telemetry: 'User viewed new builder workflow' ---
// FE counterpart of the backend 'Builder created workflow' event, which carries
@ -1603,6 +1618,7 @@ export function createThreadRuntime(
rateableResponseId,
currentTasks,
setupItemsByWorkflowId,
latestSetupWorkflowId,
contextualSuggestion,
pendingConfirmations,
isAwaitingConfirmation,

View File

@ -0,0 +1,64 @@
import type { InstanceAiSetupItem } from '@n8n/api-types';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { CORE_NODES_CATEGORY, RSS_READ_NODE_TYPE } from '@/app/constants';
import type { SetupPanelRow } from './composables/useSetupPanelState';
type CredentialRow = SetupPanelRow & {
item: Extract<InstanceAiSetupItem, { kind: 'credential' }>;
};
type ParametersRow = SetupPanelRow & {
item: Extract<InstanceAiSetupItem, { kind: 'parameters' }>;
};
export interface SetupPanelGroup {
id: string;
credential?: CredentialRow;
parameters: ParametersRow[];
/** The standalone service node, absent for the Details bucket. */
node?: INodeUi;
}
/** Group service fields with their credential and collect unrelated fields in Details. */
export function groupSetupPanelRows(
rows: SetupPanelRow[],
options: {
workflowId: string;
getNodeByName: (name: string) => INodeUi | undefined;
getNodeType: (node: INodeUi) => INodeTypeDescription | null;
},
): SetupPanelGroup[] {
const groups: SetupPanelGroup[] = rows
.filter((row): row is CredentialRow => row.item.kind === 'credential')
.map((credential) => ({ id: credential.item.id, credential, parameters: [] }));
const details: SetupPanelGroup = { id: `${options.workflowId}:details`, parameters: [] };
for (const row of rows.filter(
(entry): entry is ParametersRow => entry.item.kind === 'parameters',
)) {
const service = groups.find((group) =>
group.credential?.item.nodeBindings?.some(
(binding) => binding.nodeName === row.item.nodeName,
),
);
if (service) {
service.parameters.push(row);
continue;
}
const node = options.getNodeByName(row.item.nodeName);
const nodeType = node ? options.getNodeType(node) : null;
const categories = nodeType?.codex?.categories;
// RSS is a core node, but its feed URL belongs to a named service row.
const isRss =
node?.type === RSS_READ_NODE_TYPE || node?.type === `${RSS_READ_NODE_TYPE}Trigger`;
if (node && (isRss || (categories?.length && !categories.includes(CORE_NODES_CATEGORY)))) {
groups.push({ id: row.item.id, node, parameters: [row] });
} else {
details.parameters.push(row);
}
}
if (details.parameters.length) groups.push(details);
return groups;
}

View File

@ -0,0 +1,90 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import CredentialInputs from './CredentialInputs.vue';
const renderComponent = createComponentRenderer(CredentialInputs);
describe('compact CredentialInputs', () => {
beforeEach(() => setActivePinia(createTestingPinia()));
it('preserves multiline private keys and validates required fields only after blur', async () => {
const rendered = renderComponent({
props: {
compact: true,
credentialData: { privateKey: '' },
documentationUrl: '',
credentialProperties: [
{
name: 'privateKey',
displayName: 'Private key',
type: 'string',
required: true,
default: '',
typeOptions: { rows: 4, password: true },
},
],
},
});
const input = rendered.getByLabelText('Private key');
expect(input.tagName).toBe('TEXTAREA');
expect(input).toHaveAttribute('rows', '4');
expect(rendered.queryByRole('alert')).toBeNull();
await fireEvent.blur(input);
expect(rendered.getByRole('alert')).toHaveTextContent('This field is required');
const key = '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----';
await fireEvent.update(input, key);
expect(rendered.emitted('update')).toContainEqual([{ name: 'privateKey', value: key }]);
await rendered.rerender({ credentialData: { privateKey: key } });
expect(rendered.queryByRole('alert')).toBeNull();
expect(input).toHaveValue(key);
});
it('keeps compact OAuth labels visible and focuses their inputs on click', async () => {
const rendered = renderComponent({
props: {
compact: true,
credentialType: 'oAuth2Api',
documentationUrl: '',
credentialData: { clientId: '', clientSecret: '' },
credentialProperties: [
{
name: 'clientId',
displayName: 'Client ID',
type: 'string',
default: '',
required: true,
placeholder: 'app-client-id',
},
{
name: 'clientSecret',
displayName: 'Client Secret',
type: 'string',
default: '',
required: true,
typeOptions: { password: true },
},
],
},
});
const client = rendered.getByRole('textbox', { name: 'Client ID' });
const clientLabel = rendered.getByText('Client ID');
const secretLabel = rendered.getByText('Client Secret');
expect(clientLabel.closest('label')).toHaveAttribute('for', client.id);
expect(clientLabel).toBeVisible();
expect(secretLabel).toBeVisible();
expect(client).toHaveAttribute('placeholder', 'app-client-id');
expect(rendered.getByLabelText('Client Secret')).toHaveAttribute('type', 'password');
await userEvent.click(clientLabel);
expect(client).toHaveFocus();
await fireEvent.update(client, 'app-client');
expect(rendered.emitted('update')).toContainEqual([{ name: 'clientId', value: 'app-client' }]);
await rendered.rerender({
credentialData: { clientId: 'app-client', clientSecret: 'demo-secret' },
});
expect(clientLabel).toBeVisible();
expect(secretLabel).toBeVisible();
});
});

View File

@ -8,17 +8,30 @@ import type { IUpdateInformation } from '@/Interface';
import CopyInput from '@/app/components/CopyInput.vue';
import ParameterInputExpanded from '@/features/ndv/parameters/components/ParameterInputExpanded.vue';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { computed } from 'vue';
import { computed, reactive, useId } from 'vue';
import { N8nNotice } from '@n8n/design-system';
import { N8nInput, N8nInputLabel, N8nNotice, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
type Props = {
credentialProperties: INodeProperties[];
credentialData: ICredentialDataDecryptedObject;
documentationUrl: string;
showValidationWarnings?: boolean;
compact?: boolean;
credentialType?: string;
};
const props = defineProps<Props>();
const i18n = useI18n();
const inputId = useId();
const touched = reactive(new Set<string>());
function hasRequiredError(parameter: INodeProperties) {
return (
parameter.required &&
(touched.has(parameter.name) || props.showValidationWarnings) &&
!props.credentialData[parameter.name]
);
}
const { check: envFeatureFlag } = useEnvFeatureFlag();
@ -42,7 +55,11 @@ function valueChanged(parameterData: IUpdateInformation) {
</script>
<template>
<div v-if="visibleProperties.length" :class="$style.container" @keydown.stop>
<div
v-if="visibleProperties.length"
:class="[$style.container, { [$style.compact]: compact }]"
@keydown.stop
>
<form
v-for="parameter in visibleProperties"
:key="parameter.name"
@ -58,6 +75,57 @@ function valueChanged(parameterData: IUpdateInformation) {
:hint="parameter.description"
:value="String(credentialDataValues[parameter.name] ?? parameter.default ?? '')"
/>
<N8nInputLabel
v-else-if="compact && parameter.type === 'string' && !parameter.typeOptions?.editor"
:input-name="`${inputId}-${parameter.name}`"
:label="i18n.credText(credentialType ?? '').inputLabelDisplayName(parameter)"
:tooltip-text="
i18n.credText(credentialType ?? '').inputLabelDescription(parameter) ||
i18n.credText(credentialType ?? '').hint(parameter)
"
:bold="false"
show-tooltip
size="small"
>
<N8nInput
:id="`${inputId}-${parameter.name}`"
:model-value="
typeof credentialDataValues[parameter.name] === 'string'
? String(credentialDataValues[parameter.name])
: ''
"
:type="
parameter.typeOptions?.rows
? 'textarea'
: parameter.typeOptions?.password
? 'password'
: 'text'
"
:rows="parameter.typeOptions?.rows"
:masked="Boolean(parameter.typeOptions?.rows && parameter.typeOptions?.password)"
:aria-invalid="Boolean(hasRequiredError(parameter))"
:aria-describedby="
hasRequiredError(parameter) ? `${inputId}-${parameter.name}-error` : undefined
"
:placeholder="i18n.credText(credentialType ?? '').placeholder(parameter)"
:aria-label="i18n.credText(credentialType ?? '').inputLabelDisplayName(parameter)"
:required="parameter.required"
:autocomplete="parameter.typeOptions?.password ? 'new-password' : 'off'"
size="small"
@blur="touched.add(parameter.name)"
@update:model-value="valueChanged({ name: parameter.name, value: $event })"
/>
<N8nText
v-if="hasRequiredError(parameter)"
:id="`${inputId}-${parameter.name}-error`"
class="mt-2xs"
color="danger"
size="small"
role="alert"
>
{{ i18n.baseText('parameterInputExpanded.thisFieldIsRequired') }}
</N8nText>
</N8nInputLabel>
<ParameterInputExpanded
v-else
:parameter="parameter"
@ -65,7 +133,8 @@ function valueChanged(parameterData: IUpdateInformation) {
:node-values="credentialDataValues"
:documentation-url="documentationUrl"
:show-validation-warnings="showValidationWarnings"
:label="{ size: 'medium' }"
:label="{ size: compact ? 'small' : 'medium' }"
:hide-required-indicator="compact"
event-source="credentials"
@update="valueChanged"
/>
@ -79,4 +148,14 @@ function valueChanged(parameterData: IUpdateInformation) {
margin-bottom: var(--spacing--lg);
}
}
.compact {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
> * {
margin-bottom: 0;
}
}
</style>

View File

@ -3,6 +3,7 @@ import startCase from 'lodash/startCase';
import type { IUpdateInformation } from '@/Interface';
import ParameterInputExpanded from '@/features/ndv/parameters/components/ParameterInputExpanded.vue';
import CredentialInputs from './CredentialInputs.vue';
import {
cleanPlaceholderValue,
extractTemplateMarkers,
@ -23,11 +24,7 @@ import {
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type {
ICredentialDataDecryptedObject,
INodeProperties,
NodeParameterValueType,
} from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, INodeProperties } from 'n8n-workflow';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import { computed, ref, watch } from 'vue';
@ -45,6 +42,8 @@ import { computed, ref, watch } from 'vue';
*/
const props = defineProps<{
credentialData: ICredentialDataDecryptedObject;
/** Show only the guided inputs inside the setup panel. */
compact?: boolean;
}>();
const emit = defineEmits<{
@ -131,7 +130,7 @@ const placeholderProperties = computed<INodeProperties[]>(() =>
// sentinel — the blanking constant would read as a real value there).
// Composition still reads `editedValues`, so an untouched input keeps sending
// `***` (the server merge-back contract).
const parameterValues = computed<Record<string, NodeParameterValueType>>(() =>
const parameterValues = computed<Record<string, string>>(() =>
Object.fromEntries(
markers.value.map((name, index) => {
const value = editedValues.value[name] ?? '';
@ -231,8 +230,16 @@ const typeOptions = [
<template>
<div :class="$style.view" data-test-id="templated-auth-simple-view" @keydown.stop>
<!-- ── Guided form ── -->
<template v-if="!editing">
<template v-if="markers.length">
<template v-if="compact || !editing">
<CredentialInputs
v-if="compact && markers.length"
compact
:credential-properties="placeholderProperties"
:credential-data="parameterValues"
documentation-url=""
@update="onParameterUpdate"
/>
<template v-else-if="markers.length">
<!-- form-per-input matches CredentialInputs: breaks up inputs and prevents Chrome autofill -->
<form
v-for="parameter in placeholderProperties"
@ -257,7 +264,7 @@ const typeOptions = [
{{ i18n.baseText('credentialEdit.templatedAuth.noFieldsYet') }}
</div>
<div :class="$style.provenance">
<div v-if="!compact" :class="$style.provenance">
<N8nButton
variant="subtle"
size="small"

View File

@ -441,6 +441,21 @@ describe('NodeCredentials', () => {
});
});
it('uses an explicit setup workflow even when the global editor has no saved workflow', () => {
workflowsStore.isNewWorkflow = true;
projectsStore.currentProject = { id: 'project-1' } as Project;
workflowDocumentStoreRef.value = null;
renderComponent(
{
props: { node: httpNode, standalone: true, workflowId: 'wf-setup', projectId: 'project-1' },
},
{ merge: true },
);
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
workflowId: 'wf-setup',
});
});
it('should fall back to the personal project for an unsaved workflow without a current project', () => {
workflowsStore.isNewWorkflow = true;
projectsStore.currentProject = null;
@ -3277,7 +3292,10 @@ describe('NodeCredentials', () => {
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: false },
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
authorizeMock.mockResolvedValueOnce(true);
const { emitted } = renderComponent({
props: { node: notionNode, overrideCredType: 'openAiApi' },
});
await userEvent.click(screen.getByTestId('node-credential-private-connect'));
@ -3285,6 +3303,8 @@ describe('NodeCredentials', () => {
expect.objectContaining({ id: 'private-cred-id' }),
);
expect(uiStore.openExistingCredential).not.toHaveBeenCalled();
expect(emitted('connectionStarted')).toEqual([['private-cred-id']]);
await waitFor(() => expect(emitted('connectionCompleted')).toEqual([['private-cred-id']]));
});
it('connects via OAuth even when the user has edit rights (single flow for all)', async () => {

View File

@ -31,7 +31,11 @@ import {
import TitledList from '@/app/components/TitledList.vue';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { ChatHubToolContextKey, CREDENTIAL_ONLY_NODE_PREFIX } from '@/app/constants';
import {
AI_GATEWAY_UNSUPPORTED_NODE_TYPES,
ChatHubToolContextKey,
CREDENTIAL_ONLY_NODE_PREFIX,
} from '@/app/constants';
import { ndvEventBus } from '@/features/ndv/shared/ndv.eventBus';
import { useCredentialsStore, type CredentialFetchScope } from '../credentials.store';
import { useQuickConnect } from '../quickConnect/composables/useQuickConnect';
@ -74,20 +78,6 @@ import {
} from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
// Nodes that let the user pick their own predefined credential type via a
// parameter ("Authentication" → "Predefined Credential Type") rather than
// declaring a fixed credential in their node type. Gateway credits mints a
// managed credential for a specific, known provider — it can't stand in for
// an arbitrary user-chosen one, so these nodes never offer it. Includes the
// AI-Agent-tool variants ("Tool" suffix) generated from the same node types.
const AI_GATEWAY_UNSUPPORTED_NODE_TYPES: readonly string[] = [
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.httpRequestTool',
'@n8n/n8n-nodes-langchain.toolHttpRequest',
'n8n-nodes-base.graphql',
'n8n-nodes-base.graphqlTool',
];
type Props = {
node: INodeUi;
overrideCredType?: NodeParameterValueType;
@ -155,6 +145,8 @@ const emit = defineEmits<{
credentialSelected: [credential: INodeUpdatePropertiesInformation];
valueChanged: [value: { name: string; value: NodeParameterValueType }];
blur: [source: string];
connectionStarted: [credentialId: string];
connectionCompleted: [credentialId: string];
}>();
const telemetry = useTelemetry();
@ -301,7 +293,7 @@ function isCredentialResolvable(credentialType: string): boolean {
if (!isPrivateCredentialsEnabled.value) return false;
const credentialId = selected.value[credentialType]?.id;
if (!credentialId) return false;
const credential = credentialsStore.getCredentialById(credentialId);
const credential = findDisplayedCredential(credentialType, credentialId);
return credential?.isResolvable === true;
}
@ -309,7 +301,7 @@ function getSelectedPrivateCredential(credentialType: string): ICredentialsRespo
if (!isPrivateCredentialsEnabled.value) return null;
const id = selected.value[credentialType]?.id;
if (!id) return null;
const credential = credentialsStore.getCredentialById(id);
const credential = findDisplayedCredential(credentialType, id);
return credential?.isResolvable === true ? credential : null;
}
@ -330,9 +322,11 @@ function canConnectPrivateCredential(credentialType: string): boolean {
async function onConnectFromRow(credentialType: string): Promise<void> {
const credential = getSelectedPrivateCredential(credentialType);
if (!credential) return;
emit('connectionStarted', credential.id);
const success = await authorize(credential);
if (success) {
credentialsStore.setConnectedByMe(credential.id, true, await fetchMyAccount(credential.id));
emit('connectionCompleted', credential.id);
}
}
@ -489,6 +483,7 @@ watch(
);
function getCredentialFetchScope(): CredentialFetchScope | undefined {
if (props.workflowId) return { workflowId: props.workflowId };
const workflowId = workflowDocumentStore?.value.workflowId;
if (workflowId && !workflowsStore.isNewWorkflow) {
return { workflowId };
@ -1174,6 +1169,9 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
nodeType: props.node.type,
source: 'node_type',
serviceName,
projectId: props.projectId,
workflowId: telemetryWorkflowId.value || undefined,
credentialFetchScope: getCredentialFetchScope(),
});
if (credential) {
@ -1387,9 +1385,8 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
@visible-change="(isVisible: boolean) => onSelectVisibleChange(type.name, isVisible)"
@blur="emit('blur', 'credentials')"
>
<template #prefix>
<template v-if="selectedCredentialIcon(type.name)" #prefix>
<N8nIcon
v-if="selectedCredentialIcon(type.name)"
:icon="selectedCredentialIcon(type.name)!"
size="large"
:class="$style.optionIcon"

View File

@ -922,6 +922,50 @@ describe('useCredentialOAuth', () => {
);
});
it('uses explicit workflow and project context with self-hosted client data', async () => {
const credentialsStore = setupSuccessfulOAuthFlow();
credentialsStore.fetchUsableCredentials.mockResolvedValue([]);
await useCredentialOAuth().createAndAuthorize('slackOAuth2Api', 'n8n-nodes-base.slack', {
projectId: 'workflow-project',
workflowId: 'setup-workflow',
name: 'Custom account',
data: { clientId: 'client', clientSecret: 'secret', allowedHttpRequestDomains: 'all' },
});
expect(credentialsStore.getNewCredentialName).not.toHaveBeenCalled();
expect(credentialsStore.createNewCredential).toHaveBeenCalledWith(
{
id: '',
type: 'slackOAuth2Api',
name: 'Custom account',
data: { clientId: 'client', clientSecret: 'secret', allowedHttpRequestDomains: 'all' },
},
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
workflowId: 'setup-workflow',
});
expect(mockTrack).toHaveBeenCalledWith(
'User saved credentials',
expect.objectContaining({ workflow_id: 'setup-workflow' }),
);
});
it('keeps project scope when quick connecting from an unsaved workflow', async () => {
const store = setupSuccessfulOAuthFlow();
store.fetchUsableCredentials.mockResolvedValue([]);
await useCredentialOAuth().createAndAuthorize('slackOAuth2Api', undefined, {
workflowId: 'unsaved-workflow',
projectId: 'project-1',
credentialFetchScope: { projectId: 'project-1' },
});
expect(store.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'project-1' });
expect(store.fetchUsableCredentials).not.toHaveBeenCalledWith({
workflowId: 'unsaved-workflow',
});
});
it('should set allowedHttpRequestDomains when property is not hidden', async () => {
const credentialsStore = setupSuccessfulOAuthFlow();
credentialsStore.state.credentialTypes.customOAuth2Api =

View File

@ -529,6 +529,9 @@ export function useCredentialForm(options: UseCredentialFormOptions) {
credentialTypeName.value === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE
? toValue(options.setupHint)
: undefined;
// Render the form immediately; generating its saved name can wait for the server.
setCredentialPropertyDefaults();
if (setupHint) seedFromSetupHint(setupHint);
// Recipe-created credentials carry the creator's name ("fal.ai API Key
// (Jan D)") so same-recipe credentials stay tellable-apart in shared
// projects. A host-suggested name still needs the numbering dedup —
@ -545,8 +548,6 @@ export function useCredentialForm(options: UseCredentialFormOptions) {
credentialTypeName: credentialTypeName.value,
})
: (credentialType.value?.displayName ?? '');
setCredentialPropertyDefaults();
if (setupHint) seedFromSetupHint(setupHint);
if (homeProject.value) {
credentialData.value = { ...credentialData.value, homeProject: homeProject.value };
}

View File

@ -12,7 +12,7 @@ import {
type INodeProperties,
} from 'n8n-workflow';
import { useCredentialsStore } from '../credentials.store';
import { useCredentialsStore, type CredentialFetchScope } from '../credentials.store';
import type { ICredentialsResponse } from '../credentials.types';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -36,6 +36,11 @@ interface OAuthAuthorizationOptions {
interface CreateAndAuthorizeOptions {
onAuthorizationStarted?: (reopen: () => void) => void;
projectId?: string;
workflowId?: string;
credentialFetchScope?: CredentialFetchScope;
data?: ICredentialDataDecryptedObject;
name?: string;
}
/**
@ -347,9 +352,13 @@ export function useCredentialOAuth() {
* offer the credential until the next scoped fetch. Ask the server rather than
* inserting locally: only it can say whether the credential is usable here.
*/
async function publishConnectedCredential(credential: ICredentialsResponse): Promise<void> {
async function publishConnectedCredential(
credential: ICredentialsResponse,
scope?: CredentialFetchScope,
): Promise<void> {
credentialsStore.upsertCredential(credential);
await credentialsStore.refreshUsableCredentials();
if (scope) await credentialsStore.fetchUsableCredentials(scope);
else await credentialsStore.refreshUsableCredentials();
}
async function authorizeNewCredential(
@ -423,20 +432,22 @@ export function useCredentialOAuth() {
reopenedPopup.focus();
});
const data: ICredentialDataDecryptedObject = {};
const data: ICredentialDataDecryptedObject = { ...options.data };
const allowedHttpRequestDomainsProperty = credentialType.properties.find(
(prop) => prop.name === 'allowedHttpRequestDomains',
);
if (!allowedHttpRequestDomainsProperty || allowedHttpRequestDomainsProperty.type !== 'hidden') {
data.allowedHttpRequestDomains = 'none';
data.allowedHttpRequestDomains ??= 'none';
}
let credential: ICredentialsResponse;
try {
const name = await credentialsStore.getNewCredentialName({
credentialTypeName,
fallbackName: credentialType.displayName,
});
const name =
options.name ??
(await credentialsStore.getNewCredentialName({
credentialTypeName,
fallbackName: credentialType.displayName,
}));
credential = await credentialsStore.createNewCredential(
{
id: '',
@ -444,7 +455,7 @@ export function useCredentialOAuth() {
type: credentialTypeName,
data,
},
projectsStore.currentProject?.id,
options.projectId ?? projectsStore.currentProject?.id,
undefined,
{ skipStoreUpdate: true },
);
@ -452,7 +463,7 @@ export function useCredentialOAuth() {
telemetry.track('User created credentials', {
credential_type: credential.type,
credential_id: credential.id,
workflow_id: workflowsStore.workflowId,
workflow_id: options.workflowId ?? workflowsStore.workflowId,
});
} catch (error) {
popup.window.close();
@ -472,7 +483,7 @@ export function useCredentialOAuth() {
const trackProperties: Record<string, GenericValue> = {
credential_type: credentialTypeName,
workflow_id: workflowsStore.workflowId ?? null,
workflow_id: options.workflowId ?? workflowsStore.workflowId ?? null,
credential_id: credential.id,
is_complete: true,
is_new: true,
@ -487,7 +498,11 @@ export function useCredentialOAuth() {
telemetry.track('User saved credentials', trackProperties);
if (success) {
await publishConnectedCredential(credential);
await publishConnectedCredential(
credential,
options.credentialFetchScope ??
(options.workflowId ? { workflowId: options.workflowId } : undefined),
);
return credential;
}

View File

@ -175,6 +175,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
return allUsableCredentialsByType.value[credentialType] || [];
};
});
const getUsableCredentialById = (id: string) => usableCredentials.value[id];
const isCredentialTypeTestable = computed(() => {
return (credentialTypeName: string): boolean => {
@ -370,10 +371,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
rootStore.restApiContext,
options,
);
// The flat map keeps replace semantics for its existing consumers; the slice is
// what the credential picker reads.
setCredentials(credentials);
// Only the newest request publishes. Several scoped fetches can be in flight at
// once — a mount racing the refresh a quick connect triggers, say — and an older
// response landing last would reinstate a list we already know is out of date,
@ -381,6 +378,8 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
if (requestId !== usableCredentialsRequestId) {
return credentials;
}
// Keep the flat map and the scoped picker on the same response.
setCredentials(credentials);
usableCredentials.value = credentials.reduce((accu: ICredentialMap, cred) => {
if (cred.id) {
@ -517,6 +516,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
connectedByMe: boolean,
connectedAccountIdentifier?: string,
) => {
const usable = usableCredentials.value[id];
if (usable)
usableCredentials.value[id] = { ...usable, connectedByMe, connectedAccountIdentifier };
const existing = state.value.credentials[id];
if (existing) {
state.value.credentials = {
@ -642,6 +644,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
getCredentialByIdAndType,
isCredentialTypeTestable,
getUsableCredentialByType,
getUsableCredentialById,
credentialTypesById,
httpOnlyCredentialTypes,
getScopesByCredentialType,
@ -698,6 +701,8 @@ export const listenForCredentialChanges = (opts: {
switch (name) {
case 'createNewCredential':
// Connection flows publish only after authorization or testing succeeds.
if (args[3]?.skipStoreUpdate) return;
const createdCredential = returnValue as unknown as ICredentialsResponse;
onCredentialCreated?.(createdCredential);
break;

View File

@ -6,7 +6,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import { computed, onBeforeUnmount, ref, h } from 'vue';
import { sanitizeHtml } from '@/app/utils/htmlUtils';
import type { ICredentialsResponse } from '../../credentials.types';
import type { CredentialFetchScope, ICredentialsResponse } from '../../credentials.types';
import { useCredentialOAuth } from '../../composables/useCredentialOAuth';
import { useCredentialsStore } from '../../credentials.store';
import { useToast } from '@n8n/composables/useToast';
@ -143,6 +143,9 @@ export function useQuickConnect() {
nodeType: string;
source: 'node_type' | 'credential_type';
serviceName: string;
projectId?: string;
workflowId?: string;
credentialFetchScope?: CredentialFetchScope;
}): Promise<ICredentialsResponse | null> {
cleanUpDanglingHandlers();
const { credentialTypeName, nodeType, source } = connectParams;
@ -154,7 +157,14 @@ export function useQuickConnect() {
});
if (isOAuthCredentialType(credentialTypeName)) {
const credential = await createAndAuthorize(credentialTypeName, nodeType);
const credential =
connectParams.projectId || connectParams.workflowId
? await createAndAuthorize(credentialTypeName, nodeType, {
projectId: connectParams.projectId,
workflowId: connectParams.workflowId,
credentialFetchScope: connectParams.credentialFetchScope,
})
: await createAndAuthorize(credentialTypeName, nodeType);
return credential;
}
@ -201,7 +211,7 @@ export function useQuickConnect() {
allowedHttpRequestDomains: 'none',
},
},
projectsStore.currentProject?.id,
connectParams.projectId ?? projectsStore.currentProject?.id,
);
return credential;

View File

@ -3,6 +3,7 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import AgentSelectorParameterInput, { type Props } from './AgentSelectorParameterInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
@ -84,6 +85,45 @@ describe('AgentSelectorParameterInput', () => {
vi.clearAllMocks();
});
it.each([undefined, 'Previously selected agent'])(
'hides setup markers while preserving the cached name: %s',
async (cachedResultName) => {
const view = renderComponent({
props: makeProps({
modelValue: {
__rl: true,
mode: 'list',
value: '<__PLACEHOLDER_VALUE__Choose an agent__>',
cachedResultName,
},
}),
});
await flushPromises();
const input = view.getByRole('textbox');
expect(input).not.toHaveDisplayValue(/__PLACEHOLDER_VALUE__/);
expect(input).toHaveValue(cachedResultName ?? '');
expect(getAgent).not.toHaveBeenCalled();
expect(view.queryByTestId('rlc-open-resource-link')).not.toBeInTheDocument();
expect(view.emitted('update:modelValue')).toBeUndefined();
},
);
it('closes with Escape and reopens its agent list', async () => {
listAgentsPage.mockResolvedValue({
count: 1,
data: [{ id: 'agent-choice', name: 'Agent option', projectId: 'proj-1' }],
});
const view = renderComponent({ props: makeProps() });
await flushPromises();
await userEvent.click(view.getByTestId('rlc-input'));
expect(await view.findByText('Agent option')).toBeVisible();
await userEvent.click(view.getByTestId('rlc-search'));
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(view.queryByText('Agent option')).toBeNull());
await userEvent.click(view.getByTestId('rlc-input'));
expect(await view.findByText('Agent option')).toBeVisible();
});
it('fetches the first project-scoped page on mount', async () => {
renderComponent({ props: makeProps() });
await flushPromises();

View File

@ -3,6 +3,7 @@ import type { ComponentPublicInstance } from 'vue';
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { isPlaceholderValue } from '@n8n/utils/placeholder';
import type {
INodeParameterResourceLocator,
INodeProperties,
@ -118,15 +119,11 @@ const { hideDropdown, isDropdownVisible, showDropdown } = useResourceLocatorDrop
);
const valueToDisplay = computed<INodeParameterResourceLocator['value']>(() => {
if (typeof props.modelValue !== 'object') {
return props.modelValue ?? '';
}
if (isListMode.value) {
return props.modelValue ? (props.modelValue.cachedResultName ?? props.modelValue.value) : '';
}
return props.modelValue ? props.modelValue.value : '';
const value = typeof props.modelValue === 'object' ? props.modelValue?.value : props.modelValue;
const fallback = isPlaceholderValue(value) ? '' : (value ?? '');
return isListMode.value && typeof props.modelValue === 'object'
? (props.modelValue?.cachedResultName ?? fallback)
: fallback;
});
const placeholder = computed(() => {
@ -143,7 +140,7 @@ const placeholder = computed(() => {
const agentUrl = computed(() => {
if (!isListMode.value || !projectId.value) return null;
const agentId = props.modelValue?.value;
if (typeof agentId !== 'string' || !agentId) return null;
if (typeof agentId !== 'string' || !agentId || isPlaceholderValue(agentId)) return null;
return router.resolve({
name: AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId },
@ -229,7 +226,12 @@ async function refreshCachedAgent() {
// Read-only surfaces (execution preview, history) must never write the param.
if (props.isReadOnly) return;
const modelValue = props.modelValue;
if (modelValue?.mode !== 'list' || typeof modelValue.value !== 'string' || !modelValue.value) {
if (
modelValue?.mode !== 'list' ||
typeof modelValue.value !== 'string' ||
!modelValue.value ||
isPlaceholderValue(modelValue.value)
) {
return;
}
const freshName = await refreshAgentName(modelValue.value);
@ -308,6 +310,7 @@ defineExpose({ showDropdown });
:model-value="modelValue"
:disable-inactive-items="false"
@update:model-value="onListItemSelected"
@update:show="!$event && hideDropdown()"
@filter="onSearchFilter"
@load-more="loadMore"
>

View File

@ -171,10 +171,15 @@ function optionSelected(action: string) {
<template>
<div
:class="{ [$style.assignmentCollection]: true, [$style.empty]: empty }"
:class="{
[$style.assignmentCollection]: true,
[$style.empty]: empty,
[$style.restricted]: hasRestrictedAssignments,
}"
:data-test-id="`assignment-collection-${parameter.name}`"
>
<N8nInputLabel
v-if="!hasRestrictedAssignments"
:label="parameter.displayName"
:show-expression-selector="false"
size="small"
@ -296,6 +301,10 @@ function optionSelected(action: string) {
padding-left: var(--spacing--lg);
}
.restricted .assignments {
gap: var(--spacing--xs);
}
.dropAreaWrapper {
cursor: pointer;

View File

@ -3,6 +3,8 @@ import { computed, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, ref,
import { computedAsync, useDebounceFn, useElementSize } from '@vueuse/core';
import get from 'lodash/get';
import truncate from 'lodash/truncate';
import { CompactParameterHintsKey } from '@/app/constants/injectionKeys';
import type { INodeUpdatePropertiesInformation, IUpdateInformation, InputSize } from '@/Interface';
import type {
@ -850,13 +852,19 @@ function credentialSelected(updateInformation: INodeUpdatePropertiesInformation)
void externalHooks.run('nodeSettings.credentialSelected', { updateInformation });
}
function getPlaceholder(): string {
const compactHints = inject(CompactParameterHintsKey, false);
const fullPlaceholderHint = computed(() => {
const rawValue = isResourceLocatorValue(props.modelValue)
? props.modelValue.value
: props.modelValue;
if (typeof rawValue === 'string') {
const labels = extractPlaceholderLabels(rawValue);
if (labels.length > 0) return labels[0];
return typeof rawValue === 'string' ? extractPlaceholderLabels(rawValue)[0] : undefined;
});
function getPlaceholder(): string {
if (fullPlaceholderHint.value) {
return compactHints
? truncate(fullPlaceholderHint.value, { length: 60, separator: ' ' })
: fullPlaceholderHint.value;
}
return props.isForCredential
@ -1914,7 +1922,8 @@ onUpdated(async () => {
remoteParameterOptionsLoading ||
remoteParameterOptionsLoadingIssues !== null
"
:title="displayTitle"
:title="compactHints && fullPlaceholderHint ? fullPlaceholderHint : displayTitle"
:aria-label="compactHints ? switchLabel : undefined"
:placeholder="getPlaceholder()"
data-test-id="parameter-input-field"
@update:model-value="

View File

@ -33,6 +33,7 @@ type Props = {
documentationUrl?: string;
eventSource?: string;
label?: IParameterLabel;
hideRequiredIndicator?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@ -175,7 +176,7 @@ defineExpose({
<N8nInputLabel
:label="i18n.credText(activeCredentialType).inputLabelDisplayName(parameter)"
:tooltip-text="i18n.credText(activeCredentialType).inputLabelDescription(parameter)"
:required="parameter.required"
:required="parameter.required && !hideRequiredIndicator"
:show-tooltip="focused"
:show-options="menuExpanded"
:data-test-id="parameter.name"

View File

@ -996,6 +996,7 @@ watch(
/>
<AssignmentCollection
v-else-if="item.parameter.type === 'assignmentCollection'"
class="parameter-item"
:parameter="item.parameter"
:value="getParameterValue<AssignmentCollectionValue>(item.parameter.name)"
:path="item.path"

View File

@ -155,6 +155,31 @@ describe('ResourceLocator', () => {
});
});
it('reopens a non-searchable list after Escape closes it', async () => {
nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
results: [{ name: 'Choice', value: 'choice' }],
});
const view = renderComponent({
props: {
parameter: {
...TEST_PARAMETER_MULTI_MODE,
modes: TEST_PARAMETER_MULTI_MODE.modes?.map((mode) => ({
...mode,
typeOptions: { ...mode.typeOptions, searchable: false },
})),
},
},
});
const input = view.getByTestId('rlc-input');
await userEvent.click(input);
expect(await view.findByText('Choice')).toBeVisible();
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(input).not.toHaveFocus());
expect(view.queryByText('Choice')).toBeNull();
await userEvent.click(input);
expect(await view.findByText('Choice')).toBeVisible();
});
it('renders add resource button', async () => {
nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
results: [],

View File

@ -21,6 +21,7 @@ import {
import stringify from 'fast-json-stable-stringify';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { extractPlaceholderLabels, isPlaceholderValue } from '@n8n/utils/placeholder';
import {
isResourceLocatorValue,
type INode,
@ -160,6 +161,11 @@ const dropdownRef = ref<InstanceType<typeof ResourceLocatorDropdown>>();
const showSlowLoadNotice = ref(false);
const longLoadingTimer = ref<NodeJS.Timeout | null>(null);
// Leaving the dropdown's search field does not blur the locator input again.
watch(resourceDropdownVisible, (visible) => {
if (!visible) emit('blur');
});
const nodeTypesStore = useNodeTypesStore();
const ndvStore = injectNDVStore();
const rootStore = useRootStore();
@ -226,7 +232,20 @@ const credentialsRequiredAndNotSet = computed(() => {
return false;
});
const resourceValue = computed(() =>
isResourceLocatorValue(props.modelValue) ? props.modelValue.value : props.modelValue,
);
const isValueEmpty = computed(
() =>
resourceValue.value === '' ||
resourceValue.value === null ||
resourceValue.value === undefined ||
isPlaceholderValue(resourceValue.value),
);
const inputPlaceholder = computed(() => {
const label = extractPlaceholderLabels(resourceValue.value)[0];
if (label) return label;
if (currentMode.value.placeholder) {
return currentMode.value.placeholder;
}
@ -249,6 +268,7 @@ const hasMultipleModes = computed(() => {
const hasOnlyListMode = computed(() => hasOnlyListModeUtil(props.parameter));
const valueToDisplay = computed<INodeParameterResourceLocator['value']>(() => {
if (isValueEmpty.value) return '';
if (typeof props.modelValue !== 'object') {
return `${props.modelValue}`;
}
@ -261,6 +281,7 @@ const valueToDisplay = computed<INodeParameterResourceLocator['value']>(() => {
});
const urlValue = computedAsync(async () => {
if (isValueEmpty.value) return null;
if (isListMode.value && typeof props.modelValue === 'object') {
return props.modelValue?.cachedResultUrl ?? null;
}
@ -1082,8 +1103,8 @@ function removeOverride() {
>
<ResourceLocatorDropdown
ref="dropdownRef"
:model-value="modelValue"
:show="resourceDropdownVisible"
:model-value="modelValue"
:filterable="isSearchable"
:filter-required="requiresSearchFilter"
:resources="currentQueryResults"
@ -1097,6 +1118,7 @@ function removeOverride() {
:slow-load-notice="slowLoadNoticeMessage"
:show-slow-load-notice="showSlowLoadNotice"
@update:model-value="onListItemSelected"
@update:show="!$event && hideResourceDropdown()"
@filter="onSearchFilter"
@load-more="loadResourcesDebounced"
@add-resource-click="onAddResourceClicked"

View File

@ -51,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
'update:modelValue': [value: INodeParameterResourceLocator['value']];
'update:show': [value: boolean];
loadMore: [];
filter: [filter: string];
addResourceClick: [];
@ -149,7 +150,10 @@ function openUrl(event: MouseEvent, url: string) {
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
if (e.key === 'Escape') {
e.stopPropagation();
emit('update:show', false);
} else if (e.key === 'ArrowDown') {
// hoverIndex 0 is reserved for the "add new resource" item
if (hoverIndex.value < sortedResources.value.length) {
hoverIndex.value++;

View File

@ -7,6 +7,8 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { createTestWorkflow } from '@/__tests__/mocks';
import WorkflowSelectorParameterInput, { type Props } from './WorkflowSelectorParameterInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
@ -92,6 +94,65 @@ describe('WorkflowSelectorParameterInput', () => {
vi.clearAllMocks();
});
it.each([undefined, 'Previously selected workflow'])(
'hides setup markers while preserving the cached name: %s',
async (cachedResultName) => {
const view = renderComponent({
props: {
modelValue: {
__rl: true,
mode: 'list',
value: '<__PLACEHOLDER_VALUE__Choose a workflow__>',
cachedResultName,
},
path: '',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
},
});
await flushPromises();
const input = view.getByRole('textbox');
expect(input).not.toHaveDisplayValue(/__PLACEHOLDER_VALUE__/);
expect(input).toHaveValue(cachedResultName ?? '');
expect(workflowsListStore.fetchWorkflow).not.toHaveBeenCalled();
expect(view.queryByTestId('rlc-open-resource-link')).not.toBeInTheDocument();
expect(view.emitted('update:modelValue')).toBeUndefined();
},
);
it('closes with Escape and reopens its workflow list', async () => {
workflowsListStore.fetchWorkflowsPage.mockResolvedValue([
{
...createTestWorkflow({ id: 'wf-choice', name: 'Workflow option' }),
description: undefined,
},
]);
const view = renderComponent({
props: {
modelValue: { __rl: true, mode: 'list', value: '' },
path: '',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
},
});
await flushPromises();
await userEvent.click(view.getByTestId('rlc-input'));
expect(await view.findByText('Workflow option')).toBeVisible();
await userEvent.click(view.getByTestId('rlc-search'));
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(view.queryByText('Workflow option')).toBeNull());
await userEvent.click(view.getByTestId('rlc-input'));
expect(await view.findByText('Workflow option')).toBeVisible();
});
it('should update cached workflow when page is visible', async () => {
const props: Props = {
modelValue: {

View File

@ -5,6 +5,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { isPlaceholderValue } from '@n8n/utils/placeholder';
import type {
INodeParameterResourceLocator,
INodeProperties,
@ -143,15 +144,11 @@ const getCreateResourceLabel = computed(() => {
});
const valueToDisplay = computed<INodeParameterResourceLocator['value']>(() => {
if (typeof props.modelValue !== 'object') {
return props.modelValue ?? '';
}
if (isListMode.value) {
return props.modelValue ? (props.modelValue.cachedResultName ?? props.modelValue.value) : '';
}
return props.modelValue ? props.modelValue.value : '';
const value = typeof props.modelValue === 'object' ? props.modelValue?.value : props.modelValue;
const fallback = isPlaceholderValue(value) ? '' : (value ?? '');
return isListMode.value && typeof props.modelValue === 'object'
? (props.modelValue?.cachedResultName ?? fallback)
: fallback;
});
const placeholder = computed(() => {
@ -163,7 +160,11 @@ const placeholder = computed(() => {
});
const showOpenResourceLink = computed(() => {
return !props.isValueExpression && props.modelValue.value;
return (
!props.isValueExpression &&
props.modelValue.value &&
!isPlaceholderValue(props.modelValue.value)
);
});
function setWidth() {
@ -228,7 +229,12 @@ function openWorkflow() {
}
async function refreshCachedWorkflow() {
if (!props.modelValue || props.modelValue.mode !== 'list' || !props.modelValue.value) {
if (
!props.modelValue ||
props.modelValue.mode !== 'list' ||
!props.modelValue.value ||
isPlaceholderValue(props.modelValue.value)
) {
return;
}
@ -271,7 +277,7 @@ watch(
// Because eagerly renaming the node when the target sub-workflow
// changed name means the workflow becomes unsaved and changed just by
// opening the ExecuteWorkflow node referencing the renamed workflow
if (old.value !== val.value) {
if (old.value !== val.value && !isPlaceholderValue(val.value)) {
applyDefaultExecuteWorkflowNodeName(val.value);
}
},
@ -349,6 +355,7 @@ const onAddResourceClicked = async () => {
:model-value="modelValue"
:disable-inactive-items="false"
@update:model-value="onListItemSelected"
@update:show="!$event && hideDropdown()"
@filter="onSearchFilter"
@load-more="populateNextWorkflowsPage"
@add-resource-click="onAddResourceClicked"

View File

@ -10,8 +10,11 @@ import type { InstanceAiAgentNode, InstanceAiSetupItem } from '@n8n/api-types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { useSetupPanelState } from '@/features/ai/instanceAi/composables/useSetupPanelState';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import {
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
@ -28,6 +31,10 @@ vi.mock('@/features/setupPanel/setupPanel.utils', () => ({
getNodeCredentialTypes: vi.fn().mockReturnValue([]),
getNodeParametersIssues: vi.fn().mockReturnValue({}),
}));
vi.mock('@n8n/rest-api-client', async (importOriginal) => ({
...(await importOriginal<object>()),
makeRestApiRequest: vi.fn(),
}));
// The credential-change test runs the real `deleteCredential` action (a
// reassigned `vi.fn()` would bypass pinia's wrapper, so `$onAction` — what
@ -35,6 +42,9 @@ vi.mock('@/features/setupPanel/setupPanel.utils', () => ({
vi.mock('@/features/credentials/credentials.api', async (importOriginal) => ({
...(await importOriginal<object>()),
deleteCredential: vi.fn().mockResolvedValue(true),
createNewCredential: vi
.fn()
.mockResolvedValue({ id: 'pending', name: 'Pending account', type: 'slackApi' }),
}));
const WORKFLOW_ID = 'wf-1';
@ -345,6 +355,50 @@ describe('useWorkflowSetupItems', () => {
});
});
it('recovers readiness after the first credential read fails', async () => {
credentialsStore.fetchUsableCredentials.mockRejectedValueOnce(new Error('Read failed'));
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
expect(state.credentialsAvailable.value).toBe(false);
credentialsStore.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
await credentialsStore.deleteCredential({ id: 'cred-1' });
await flushPromises();
expect(state.credentialsAvailable.value).toBe(true);
});
it('uses the save response immediately without waiting for another workflow fetch', async () => {
workflowsListStore.fetchWorkflow.mockResolvedValueOnce(
createTestWorkflow({ id: WORKFLOW_ID, nodes: [createTestNode({ name: 'Slack' })] }),
);
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
expect(state.getNodeByName('Slack')?.credentials).toBeUndefined();
const saved = createTestWorkflow({
id: WORKFLOW_ID,
checksum: 'saved-checksum',
nodes: [
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: 'existing', name: 'Existing account' } },
}),
],
});
vi.mocked(makeRestApiRequest).mockResolvedValueOnce(saved);
const olderRead = Promise.withResolvers<IWorkflowDb>();
workflowsListStore.fetchWorkflow.mockReturnValueOnce(olderRead.promise);
const refresh = state.refreshWorkflow();
await useWorkflowsStore().updateWorkflow(WORKFLOW_ID, { nodes: saved.nodes });
expect(state.getNodeByName('Slack')?.credentials?.slackApi).toEqual({
id: 'existing',
name: 'Existing account',
});
olderRead.resolve(
createTestWorkflow({ id: WORKFLOW_ID, nodes: [createTestNode({ name: 'Slack' })] }),
);
await refresh;
expect(state.isItemDone(credentialItem())).toBe(true);
});
// Pins that the subscription stays non-detached and registered synchronously
// in the composable body: that is what lets pinia unbind it on scope dispose,
// so a closed panel does not keep re-anchoring the shared usable slice.
@ -360,12 +414,14 @@ describe('useWorkflowSetupItems', () => {
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledTimes(1);
});
it('pauses fetching while the agent edits, then refreshes once it settles', async () => {
it('reads credentials during a build but defers the workflow fetch until it settles', async () => {
const paused = ref(true);
useWorkflowSetupItems(() => WORKFLOW_ID, { paused });
expect(credentialsStore.fetchUsableCredentials).not.toHaveBeenCalled();
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
workflowId: WORKFLOW_ID,
});
expect(workflowsListStore.fetchWorkflow).not.toHaveBeenCalled();
paused.value = false;
@ -377,6 +433,105 @@ describe('useWorkflowSetupItems', () => {
});
});
it('reads saved bindings during a build when its snapshot is explicitly refreshed', async () => {
const paused = ref(true);
const read = Promise.withResolvers<IWorkflowDb>();
const canvas = hydrateWorkflow([createTestNode({ name: 'Slack' })]);
workflowsListStore.fetchWorkflow.mockReturnValueOnce(read.promise);
const state = useWorkflowSetupItems(() => WORKFLOW_ID, { paused });
const refresh = state.refreshWorkflow({ force: true });
expect(state.isRefreshingWorkflow.value).toBe(true);
expect(state.isItemDone(credentialItem())).toBe(false);
read.resolve(
createTestWorkflow({
id: WORKFLOW_ID,
nodes: [
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: 'cred-1', name: 'Existing account' } },
}),
],
}),
);
await refresh;
expect(state.isRefreshingWorkflow.value).toBe(false);
expect(state.isItemDone(credentialItem())).toBe(true);
expect(canvas.allNodes[0].credentials).toBeUndefined();
});
it('does not refresh the usable slice before a connection flow publishes its credential', async () => {
useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledTimes(1);
await credentialsStore.createNewCredential(
{ id: '', name: 'Pending account', type: 'slackApi', data: {} },
'project',
undefined,
{ skipStoreUpdate: true },
);
await flushPromises();
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledTimes(1);
await credentialsStore.createNewCredential(
{ id: '', name: 'Saved account', type: 'slackApi', data: {} },
'project',
);
await flushPromises();
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledTimes(2);
});
it('requires a fresh credential read before reporting readiness, even with a cached scope', async () => {
const read = Promise.withResolvers<[]>();
credentialsStore.fetchUsableCredentials.mockReturnValueOnce(read.promise);
credentialsStore.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
const state = useWorkflowSetupItems(() => WORKFLOW_ID, { paused: true });
expect(state.credentialsAvailable.value).toBe(false);
read.resolve([]);
await flushPromises();
expect(state.credentialsAvailable.value).toBe(true);
});
it('derives a managed credential as complete without an ordinary credential ID', () => {
hydrateWorkflow([
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: null, name: '', __aiGatewayManaged: true } },
}),
]);
expect(useWorkflowSetupItems(() => WORKFLOW_ID).isItemDone(credentialItem())).toBe(true);
});
it('keeps an unconnected private credential pending even if another account is usable', () => {
hydrateWorkflow([
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: 'private', name: 'Private' } },
}),
]);
credentialsStore.getCredentialById = vi
.fn()
.mockReturnValue({ id: 'private', isResolvable: true, connectedByMe: false });
credentialsStore.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
credentialsStore.getUsableCredentialByType = vi.fn().mockReturnValue([{ id: 'ordinary' }]);
expect(useWorkflowSetupItems(() => WORKFLOW_ID).isItemDone(credentialItem())).toBe(false);
});
it('uses scoped connection metadata after the flat credential map is replaced', () => {
hydrateWorkflow([
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: 'private', name: 'Private' } },
}),
]);
credentialsStore.hasUsableCredentialsForScope = vi.fn().mockReturnValue(true);
credentialsStore.getUsableCredentialById.mockReturnValue({
id: 'private',
isResolvable: true,
connectedByMe: false,
} as ICredentialsResponse);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(undefined);
expect(useWorkflowSetupItems(() => WORKFLOW_ID).isItemDone(credentialItem())).toBe(false);
});
it('follows the canvas host through dispose and recreate cycles', () => {
mockGetNodeCredentialTypes.mockImplementation((_provider, node) =>
node.name === 'Slack' ? ['slackApi'] : ['notionApi'],
@ -418,7 +573,7 @@ describe('useWorkflowSetupItems', () => {
expect(isItemDone(derivedItems.value[1])).toBe(true);
});
it('completes a credential item once a usable credential of its type exists, even without the workflow document', () => {
it('keeps an available account pending until it is bound to the workflow', () => {
const getUsable = vi.fn().mockReturnValue([{ id: 'cred-1' }]);
credentialsStore.getUsableCredentialByType = getUsable;
@ -432,6 +587,15 @@ describe('useWorkflowSetupItems', () => {
expect(isItemDone(credentialItem())).toBe(false);
getUsable.mockReturnValue([{ id: 'cred-1' }]);
expect(isItemDone(credentialItem())).toBe(false);
hydrateWorkflow([createTestNode({ name: 'Slack' })]);
expect(isItemDone(credentialItem())).toBe(false);
hydrateWorkflow([
createTestNode({
name: 'Slack',
credentials: { slackApi: { id: 'cred-1', name: 'Saved account' } },
}),
]);
expect(isItemDone(credentialItem())).toBe(true);
});
@ -452,6 +616,13 @@ describe('useWorkflowSetupItems', () => {
).toBe(false);
});
it('does not treat a legacy credential name as a saved binding', () => {
const node = createTestNode({ name: 'Slack' });
Object.assign(node, { credentials: { slackApi: 'Legacy account' } });
hydrateWorkflow([node]);
expect(useWorkflowSetupItems(() => WORKFLOW_ID).isItemDone(credentialItem())).toBe(false);
});
it('completes a parameters item once the workflow no longer raises its issues', () => {
mockGetNodeParametersIssues.mockReturnValue({
documentId: ['Parameter "documentId" is required.'],

View File

@ -6,6 +6,7 @@ import type { INodeCredentialsDetails } from 'n8n-workflow';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
listenForCredentialChanges,
useCredentialsStore,
@ -21,12 +22,12 @@ import {
} from '@/features/setupPanel/setupPanel.utils';
/**
* Whether a node's credential slot holds a binding. Legacy workflow JSON may
* still carry a plain credential name, which `INodeCredentials`' value type
* doesn't admit (`useNodeHelpers` hedges against the same case).
* A legacy credential name must be replaced with a stored credential reference.
*/
function isBoundCredential(assigned: INodeCredentialsDetails | string | undefined): boolean {
return typeof assigned === 'string' ? assigned.length > 0 : Boolean(assigned?.id);
return (
typeof assigned !== 'string' && (Boolean(assigned?.id) || assigned?.__aiGatewayManaged === true)
);
}
/**
@ -39,9 +40,8 @@ function isBoundCredential(assigned: INodeCredentialsDetails | string | undefine
* Node state comes from the live document store while a canvas host has one
* hydrated, and from the saved workflow (fetched here) otherwise, so the
* derivation also works on a refreshed thread with no canvas mounted. Pass
* `paused` while the agent is editing the workflow: fetching waits until the
* edit settles, then refreshes both the workflow and the usable-credentials
* slice.
* `paused` while the agent is editing to defer workflow reads. Credential
* reads continue so connections can start during the build.
*/
export function useWorkflowSetupItems(
workflowId: MaybeRefOrGetter<string | undefined>,
@ -50,6 +50,16 @@ export function useWorkflowSetupItems(
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowsStore = useWorkflowsStore();
const credentialsLoadedForWorkflow = ref<string>();
const credentialsAvailable = computed(() => {
const id = toValue(workflowId);
return (
id !== undefined &&
credentialsLoadedForWorkflow.value === id &&
credentialsStore.hasUsableCredentialsForScope({ workflowId: id })
);
});
/**
* The canvas host's live document store, if any. Resolved fresh so a host
@ -67,27 +77,33 @@ export function useWorkflowSetupItems(
* placeholders, which would read as an empty (but "available") workflow.
*/
const fetchedWorkflow = ref<IWorkflowDb>();
const isRefreshingWorkflow = ref(false);
let workflowFetchVersion = 0;
onScopeDispose(() => workflowFetchVersion++);
async function refreshWorkflow() {
const requestVersion = ++workflowFetchVersion;
async function refreshWorkflow({ force = false } = {}) {
const id = toValue(workflowId);
if (!id) return;
if (!id || (!force && (toValue(options.paused) || documentStore.value?.hydrated))) return;
const requestVersion = ++workflowFetchVersion;
const isCurrentRequest = () =>
requestVersion === workflowFetchVersion &&
toValue(workflowId) === id &&
!toValue(options.paused) &&
!documentStore.value?.hydrated;
(force || (!toValue(options.paused) && !documentStore.value?.hydrated));
if (!isCurrentRequest()) return;
isRefreshingWorkflow.value = true;
// Retry the current read once. An older response can contain pre-build values.
for (let attempt = 0; attempt < 2 && isCurrentRequest(); attempt++) {
try {
const workflow = await workflowsListStore.fetchWorkflow(id);
if (isCurrentRequest()) fetchedWorkflow.value = workflow;
return;
} catch {
// Keep the current rows if both attempts fail.
try {
for (let attempt = 0; attempt < 2 && isCurrentRequest(); attempt++) {
try {
const workflow = await workflowsListStore.fetchWorkflow(id);
if (isCurrentRequest()) fetchedWorkflow.value = workflow;
return;
} catch {
// Keep the current rows if both attempts fail.
}
}
} finally {
if (requestVersion === workflowFetchVersion) isRefreshingWorkflow.value = false;
}
}
@ -99,8 +115,8 @@ export function useWorkflowSetupItems(
: undefined;
});
// (Re)load the derivation's inputs while not paused, and again when an
// agent edit settles or a canvas host's document store goes away. Failures
// Reload inputs when an edit settles or a canvas document goes away.
// Credential and node-type reads also run during builds. Failures
// stay silent by design — rows fall back to the thread's event feed until
// a later pass succeeds.
watch(
@ -111,10 +127,10 @@ export function useWorkflowSetupItems(
documentStore.value?.hydrated === true,
] as const,
([id, paused, hydrated]) => {
if (!id || paused) return;
if (!id) return;
void nodeTypesStore.loadNodeTypesIfNotLoaded().catch(() => {});
void credentialsStore.fetchUsableCredentials({ workflowId: id }).catch(() => {});
if (!hydrated) void refreshWorkflow();
refreshUsableSlice();
if (!paused && !hydrated) void refreshWorkflow();
},
{ immediate: true },
);
@ -124,22 +140,41 @@ export function useWorkflowSetupItems(
// page) would otherwise leave done-ness stale while this stays mounted.
// Refetch this workflow's scope, not the store's last one: the slice is
// last-writer-wins and another view may have re-anchored it elsewhere.
const refreshUsableSlice = () => {
function refreshUsableSlice() {
const id = toValue(workflowId);
if (id) void credentialsStore.fetchUsableCredentials({ workflowId: id }).catch(() => {});
};
if (id)
void credentialsStore
.fetchUsableCredentials({ workflowId: id })
.then(() => {
if (toValue(workflowId) === id) credentialsLoadedForWorkflow.value = id;
})
.catch(() => {});
}
listenForCredentialChanges({
store: credentialsStore,
onCredentialCreated: refreshUsableSlice,
onCredentialUpdated: refreshUsableSlice,
onCredentialDeleted: refreshUsableSlice,
});
workflowsStore.$onAction(({ name, args, after }) => {
if (name !== 'updateWorkflow' || args[0] !== toValue(workflowId)) return;
after((saved) => {
if (saved.id !== toValue(workflowId)) return;
// Keep the saved values visible when the write queue clears.
workflowFetchVersion++;
fetchedWorkflow.value = saved;
isRefreshingWorkflow.value = false;
});
});
/** Live canvas nodes when a host hydrated a document store, else the fetched save's. */
const workflowNodes = computed<INodeUi[] | undefined>(() => {
const docStore = documentStore.value;
if (docStore?.hydrated) return docStore.allNodes;
const id = toValue(workflowId);
// Setup announcements follow a save, before the canvas receives the build result.
if (toValue(options.paused) && fetchedWorkflow.value?.id === id)
return fetchedWorkflow.value?.nodes;
if (docStore?.hydrated) return docStore.allNodes;
return id && fetchedWorkflow.value?.id === id ? fetchedWorkflow.value.nodes : undefined;
});
@ -278,40 +313,34 @@ export function useWorkflowSetupItems(
return items;
});
/**
* Done-ness is always derived, never stored (see `setupItemSchema`): a
* credential item is done once a usable credential of its type exists —
* binding it to the node is the apply path's job — or when every bound
* node already carries one (covers credentials shared with the workflow
* but not usable by the current user). The type-level shortcut only counts
* when the usable slice was fetched for this workflow (it is a single,
* last-writer-wins scope slice) and never for generic auth types, where a
* credential of the type says nothing about this service
* (`shouldAutoResolveCredential` draws the same line). A parameters item
* is done once none of its parameters raise issues on the current
* workflow.
*/
function isItemDone(item: InstanceAiSetupItem): boolean {
function isCredentialConfigured(assigned: INodeCredentialsDetails | string | undefined): boolean {
if (!isBoundCredential(assigned)) return false;
const id = toValue(workflowId);
const credential =
typeof assigned !== 'string' && assigned?.id
? ((id && credentialsStore.hasUsableCredentialsForScope({ workflowId: id })
? credentialsStore.getUsableCredentialById(assigned.id)
: undefined) ?? credentialsStore.getCredentialById(assigned.id))
: undefined;
return !credential?.isResolvable || credential.connectedByMe !== false;
}
/** Completion requires a binding on every node, not merely an available account. */
function isItemDone(
item: InstanceAiSetupItem,
readNode: (name: string) => INodeUi | undefined = getNodeByName,
): boolean {
if (item.kind === 'credential') {
const id = toValue(workflowId);
if (
!GENERIC_AUTH_CREDENTIAL_TYPES.has(item.credentialType) &&
id !== undefined &&
credentialsStore.hasUsableCredentialsForScope({ workflowId: id }) &&
credentialsStore.getUsableCredentialByType(item.credentialType).length > 0
) {
return true;
}
const nodeNames = (item.nodeBindings ?? []).map((binding) => binding.nodeName);
return (
nodeNames.length > 0 &&
nodeNames.every((nodeName) =>
isBoundCredential(nodesByName.value.get(nodeName)?.credentials?.[item.credentialType]),
isCredentialConfigured(readNode(nodeName)?.credentials?.[item.credentialType]),
)
);
}
const node = nodesByName.value.get(item.nodeName);
const node = readNode(item.nodeName);
if (!node) return false;
const pendingNames = getPendingParameterNames(node);
return item.parameterNames.every((parameterName) => {
@ -326,11 +355,14 @@ export function useWorkflowSetupItems(
}
return {
credentialsAvailable,
isWorkflowAvailable,
workflowProjectId,
derivedItems,
derivedCredentialItems,
isItemDone,
isCredentialConfigured,
isRefreshingWorkflow,
getNodeByName,
refreshWorkflow,
};

View File

@ -38,6 +38,24 @@ describe('setupPanel.utils', () => {
});
describe('getNodeCredentialTypes', () => {
it('resolves predefined HTTP credentials before a binding or node issue exists', () => {
const node = createNode({
type: 'n8n-nodes-base.httpRequest',
parameters: { authentication: 'predefinedCredentialType', nodeCredentialType: 'slackApi' },
});
mockNodeTypeProvider.getNodeType.mockReturnValue({
credentials: [],
properties: [
{
name: 'nodeCredentialType',
displayOptions: { show: { authentication: ['predefinedCredentialType'] } },
},
],
});
expect(getNodeCredentialTypes(mockNodeTypeProvider, node)).toEqual(['slackApi']);
node.parameters.authentication = 'none';
expect(getNodeCredentialTypes(mockNodeTypeProvider, node)).toEqual([]);
});
it('should return credential types from displayable credentials', () => {
const node = createNode();
mockGetNodeTypeDisplayableCredentials.mockReturnValue([

View File

@ -18,16 +18,22 @@ import type {
import { type INode, type INodeParameters, type INodeProperties, NodeHelpers } from 'n8n-workflow';
/**
* Collects all credential types that a node requires from three sources:
* Collects all credential types that a node requires:
* 1. Node type definition — standard credentials with displayOptions
* 2. Node issues — dynamic credentials (e.g. in HTTP Request node) that are missing or invalid
* 3. Assigned credentials — dynamic credentials already properly set
* 4. Parameter-selected credentials, even before a binding or issue exists
*/
export function getNodeCredentialTypes(
nodeTypeProvider: NodeTypeProvider,
node: INodeUi,
): string[] {
const credentialTypes = new Set<string>();
const nodeType = nodeTypeProvider.getNodeType(node.type, node.typeVersion);
const activeTypes = NodeHelpers.getActiveCredentialTypes(node, nodeType);
for (const type of [node.parameters.nodeCredentialType, node.parameters.genericAuthType]) {
if (typeof type === 'string' && activeTypes?.has(type)) credentialTypes.add(type);
}
const displayableCredentials = getNodeTypeDisplayableCredentials(nodeTypeProvider, node);
for (const cred of displayableCredentials) {
@ -72,7 +78,7 @@ export function getNodeParametersIssues(nodeTypesStore: NodeTypeProvider, node:
nodeType.properties,
node.parameters,
true,
false,
true,
node,
nodeType,
) ?? node.parameters;

View File

@ -4,7 +4,12 @@ import {
} from '@/features/workflows/canvas/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/app/constants';
import { EditorEnabledFeaturesKey, WorkflowDocumentStoreKey, VIEWS } from '@/app/constants';
import { createTestNode } from '@/__tests__/mocks';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '../../../../../canvas.types';
import { createTestingPinia } from '@pinia/testing';
@ -24,6 +29,7 @@ vi.mock('vue-router', async (importOriginal) => {
const pinnedDataByNodeName: IPinData = {};
const executionPinDataByNodeId = new Map<string, ComputedRef<IPinData[string] | undefined>>();
const executionIssuesByNodeId = new Map<string, ComputedRef<string[]>>();
let isExecutionDataDisplayed = false;
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
@ -34,6 +40,7 @@ vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
value: actual.createEmptyCanvasRenderData({
pinnedDataByNodeName,
executionPinDataByNodeId,
executionIssuesByNodeId,
isExecutionDataDisplayed,
}),
})),
@ -56,9 +63,52 @@ describe('CanvasNodeStatusIcons', () => {
delete pinnedDataByNodeName[key];
}
executionPinDataByNodeId.clear();
executionIssuesByNodeId.clear();
isExecutionDataDisplayed = false;
});
it.each([
{ enabled: true, bound: false, otherIssue: false, executionError: false, pending: true },
{ enabled: false, bound: false, otherIssue: false, executionError: false, pending: false },
{ enabled: true, bound: true, otherIssue: false, executionError: false, pending: false },
{ enabled: true, bound: false, otherIssue: true, executionError: false, pending: false },
{ enabled: true, bound: false, otherIssue: false, executionError: true, pending: false },
])(
'distinguishes missing credentials from errors: %j',
({ enabled, bound, otherIssue, executionError, pending }) => {
const document = useWorkflowDocumentStore(createWorkflowDocumentId('workflow'));
vi.spyOn(document, 'getNodeById').mockReturnValue({
...createTestNode({
id: 'node',
credentials: bound ? { slackApi: { id: 'account', name: 'Slack' } } : {},
}),
issues: {
credentials: { slackApi: ['Select a credential'] },
...(otherIssue ? { parameters: { channel: ['Channel is required'] } } : {}),
},
});
if (executionError)
executionIssuesByNodeId.set(
'node',
computed(() => ['Request failed']),
);
const { getByTestId, queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({
data: { issues: { visible: true, validation: ['Select a credential'] } },
}),
[WorkflowDocumentStoreKey]: computed(() => document),
[EditorEnabledFeaturesKey]: computed(() => ({ credentialSetupWarnings: enabled })),
},
},
});
expect(getByTestId(pending ? 'node-setup-required' : 'node-issues')).toBeVisible();
expect(queryByTestId(pending ? 'node-issues' : 'node-setup-required')).toBeNull();
},
);
it('should render correctly for a pinned node', () => {
pinnedDataByNodeName['Test Node'] = [{ json: { key: 'value' } }];

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { computed, inject, useCssModule } from 'vue';
import TitledList from '@/app/components/TitledList.vue';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useCanvasNode } from '../../../../../composables/useCanvasNode';
@ -7,7 +7,8 @@ import { injectCanvasRenderData } from '@/features/workflows/canvas/canvas.utils
import { useI18n } from '@n8n/i18n';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '../../../../../canvas.types';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { EditorEnabledFeaturesKey, VIEWS } from '@/app/constants';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import CanvasNodeStatusMark from './CanvasNodeStatusMark.vue';
@ -38,6 +39,27 @@ const {
isNotInstalledCommunityNode,
} = useCanvasNode();
const renderData = injectCanvasRenderData();
const editorFeatures = inject(EditorEnabledFeaturesKey, undefined);
const workflowDocumentStore = injectWorkflowDocumentStore();
const needsCredentialSetup = computed(() => {
if (!editorFeatures?.value.credentialSetupWarnings) return false;
const node = workflowDocumentStore.value.getNodeById(id.value);
const issues = node?.issues;
const credentialTypes = Object.keys(issues?.credentials ?? {});
if (!node || !credentialTypes.length) return false;
if (
Object.entries(issues ?? {}).some(
([kind, issue]) =>
kind !== 'credentials' &&
(typeof issue === 'boolean' ? issue : Object.keys(issue ?? {}).length > 0),
)
)
return false;
return credentialTypes.every((type) => {
const credential = node.credentials?.[type];
return !credential?.id && !credential?.__aiGatewayManaged;
});
});
const executionErrors = computed(
() => renderData.value.executionIssuesByNodeId.get(id.value)?.value ?? [],
);
@ -110,14 +132,17 @@ const groupedExecutionErrors = computed(() => {
</div>
<div
v-else-if="hasValidationErrors && !hideNodeIssues"
:class="[...commonClasses, $style.issues]"
data-test-id="node-issues"
:class="[...commonClasses, needsCredentialSetup ? $style.warning : $style.issues]"
:data-test-id="needsCredentialSetup ? 'node-setup-required' : 'node-issues'"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="validationErrors" />
<TitledList
:title="i18n.baseText(needsCredentialSetup ? 'node.setupRequired' : 'node.issues')"
:items="validationErrors"
/>
</template>
<N8nIcon icon="node-validation-error" :size="size" />
<N8nIcon :icon="needsCredentialSetup ? 'key-round' : 'node-validation-error'" :size="size" />
</N8nTooltip>
</div>
<div v-else-if="executionStatus === 'unknown'">

View File

@ -18,6 +18,7 @@ export class ClearbitApi implements ICredentialType {
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
];

View File

@ -19,6 +19,7 @@ export class NotionApi implements ICredentialType {
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
];

View File

@ -20,6 +20,8 @@ export class SlackApi implements ICredentialType {
typeOptions: { password: true },
default: '',
required: true,
description:
'In your Slack app, open OAuth & Permissions. Copy the Bot User OAuth Token (xoxb-) or User OAuth Token (xoxp-), depending on the operations you need.',
},
{
displayName: 'Signature Secret',

View File

@ -13,6 +13,7 @@ export class TelegramApi implements ICredentialType {
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
description:
'Chat with the <a href="https://telegram.me/botfather">bot father</a> to obtain the access token',

Some files were not shown because too many files have changed in this diff Show More