feat(editor): Add workflow execution to AI Workflow Builder (no-changelog) (#20037)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
oleg 2025-09-29 13:35:20 +02:00 committed by GitHub
parent a5c1adf6ea
commit a71306e2d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1191 additions and 71 deletions

View File

@ -362,24 +362,42 @@ update_node_parameters({{
const responsePatterns = `
<response_patterns>
IMPORTANT: Only provide ONE response AFTER all tool execution is complete.
IMPORTANT: Only provide ONE response AFTER all tool executions are complete.
Response format:
EXCEPTION - Error handling:
When tool execution fails, provide a brief acknowledgment before attempting fixes:
- "The workflow hit an error. Let me debug this."
- "Execution failed. Let me trace the issue."
- "Got a workflow error. Investigating now."
- Or similar brief phrases
Then proceed with debugging/fixing without additional commentary.
Response format conditions:
- Include "**⚙️ How to Setup**" section ONLY if this is the initial workflow creation
- Include "**📝 What's changed**" section ONLY for non-initial modifications (skip for first workflow creation)
- Skip setup section for minor tweaks, bug fixes, or cosmetic changes
When changes section is included:
**📝 What's changed**
- Brief bullets highlighting key modifications made
- Focus on functional changes, not technical implementation details
When setup section is included:
** How to Setup** (numbered format)
- List credentials and parameters that need to configured
- Only list incomplete tasks that need user action (skip what's already configured)
- List only parameter placeholders requiring user configuration
- Include only incomplete tasks needing user action (skip pre-configured items)
- IMPORTANT: NEVER instruct user to set-up authentication or credentials for nodes - this will be handled in the UI
- IMPORTANT: Focus on workflow-specific parameters/placeholders only
** How to Use**
- Only essential user actions (what to click, where to go)
End with: "Let me know if you'd like to adjust anything."
Always end with: "Let me know if you'd like to adjust anything."
ABSOLUTELY FORBIDDEN IN BUILDING MODE:
- Any text between tool calls
- Any text between tool calls (except error acknowledgments)
- Progress updates during execution
- "Perfect!", "Now let me...", "Excellent!"
- Describing what was built
- Explaining workflow functionality
- Celebratory phrases ("Perfect!", "Now let me...", "Excellent!", "Great!")
- Describing what was built or explaining functionality
- Workflow narration or step-by-step commentary
- Status updates while tools are running
</response_patterns>
`;

View File

@ -377,6 +377,7 @@ defineExpose({
</div>
</div>
</data>
<slot name="messagesFooter" />
</div>
<div
v-if="loadingMessage"

View File

@ -253,6 +253,8 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
</div>
</data>
</div>
<!--v-if-->
</div>
@ -445,6 +447,8 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
<!--v-if-->
</data>
</div>
<!--v-if-->
</div>
@ -530,6 +534,8 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
<!--v-if-->
</data>
</div>
<!--v-if-->
</div>
@ -615,6 +621,8 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
<!--v-if-->
</data>
</div>
<!--v-if-->
</div>
@ -700,6 +708,8 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
<!--v-if-->
</data>
</div>
<!--v-if-->
</div>

View File

@ -217,6 +217,16 @@
"aiAssistant.builder.canvasPrompt.startManually.title": "Start manually",
"aiAssistant.builder.canvasPrompt.startManually.subTitle": "Add the first node",
"aiAssistant.builder.streamAbortedMessage": "[Task aborted]",
"aiAssistant.builder.executeMessage.description": "Complete these steps before executing your workflow:",
"aiAssistant.builder.executeMessage.noIssues": "Your workflow is ready to be executed",
"aiAssistant.builder.executeMessage.validationTooltip": "Complete the steps above before executing",
"aiAssistant.builder.executeMessage.execute": "Execute and refine",
"aiAssistant.builder.executeMessage.noExecutionData": "Workflow execution could not be started. Please try again.",
"aiAssistant.builder.executeMessage.executionSuccess": "Workflow executed successfully.",
"aiAssistant.builder.executeMessage.executionFailedOnNode": "Workflow execution failed on node \"{nodeName}\": {errorMessage}",
"aiAssistant.builder.executeMessage.executionFailed": "Workflow execution failed: {errorMessage}",
"aiAssistant.builder.toast.title": "Send chat message to start the execution",
"aiAssistant.builder.toast.description": "Please send a message in the chat panel to start the execution of your workflow",
"aiAssistant.assistant": "AI Assistant",
"aiAssistant.newSessionModal.title.part1": "Start new",
"aiAssistant.newSessionModal.title.part2": "session",

View File

@ -11,6 +11,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import type { RatingFeedback } from '@n8n/design-system/types/assistant';
import { isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
import { nodeViewEventBus } from '@/event-bus';
import ExecuteMessage from './ExecuteMessage.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const emit = defineEmits<{
@ -30,7 +31,6 @@ const { goToUpgrade } = usePageRedirectionHelper();
// Track processed workflow updates
const processedWorkflowUpdates = ref(new Set<string>());
const trackedTools = ref(new Set<string>());
const assistantChatRef = ref<InstanceType<typeof AskAssistantChat> | null>(null);
const workflowUpdated = ref<{ start: string; end: string } | undefined>();
const user = computed(() => ({
@ -40,9 +40,19 @@ const user = computed(() => ({
const loadingMessage = computed(() => builderStore.assistantThinkingMessage);
const currentRoute = computed(() => route.name);
const showExecuteMessage = computed(() => {
const builderUpdatedWorkflowMessageIndex = builderStore.chatMessages.findLastIndex(
(msg) => msg.type === 'workflow-updated',
);
return (
!builderStore.streaming &&
workflowsStore.workflow.nodes.length > 0 &&
builderUpdatedWorkflowMessageIndex > -1
);
});
const creditsQuota = computed(() => builderStore.creditsQuota);
const creditsRemaining = computed(() => builderStore.creditsRemaining);
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner !== false);
const showAskOwnerTooltip = computed(() => usersStore.isInstanceOwner);
async function onUserMessage(content: string) {
const isNewWorkflow = workflowsStore.isNewWorkflow;
@ -109,6 +119,56 @@ function trackWorkflowModifications() {
}
}
function onWorkflowExecuted() {
const executionData = workflowsStore.workflowExecutionData;
const executionStatus = executionData?.status ?? 'unknown';
const errorNodeName = executionData?.data?.resultData.lastNodeExecuted;
const errorNodeType = errorNodeName
? workflowsStore.workflow.nodes.find((node) => node.name === errorNodeName)?.type
: undefined;
if (!executionData) {
builderStore.sendChatMessage({
text: i18n.baseText('aiAssistant.builder.executeMessage.noExecutionData'),
type: 'execution',
executionStatus: 'error',
errorMessage: 'Workflow execution data missing after run attempt.',
});
return;
}
if (executionStatus === 'success') {
builderStore.sendChatMessage({
text: i18n.baseText('aiAssistant.builder.executeMessage.executionSuccess'),
type: 'execution',
executionStatus,
});
return;
}
const executionError = executionData.data?.resultData.error?.message ?? 'Unknown error';
const scopedErrorMessage = errorNodeName
? i18n.baseText('aiAssistant.builder.executeMessage.executionFailedOnNode', {
interpolate: {
nodeName: errorNodeName,
errorMessage: executionError,
},
})
: i18n.baseText('aiAssistant.builder.executeMessage.executionFailed', {
interpolate: { errorMessage: executionError },
});
const failureStatus = executionStatus === 'unknown' ? 'error' : executionStatus;
builderStore.sendChatMessage({
text: scopedErrorMessage,
type: 'execution',
errorMessage: executionError,
errorNodeType,
executionStatus: failureStatus,
});
}
// Watch for workflow updates and apply them
watch(
() => builderStore.workflowMessages,
@ -186,7 +246,6 @@ watch(currentRoute, () => {
<template>
<div data-test-id="ask-assistant-chat" tabindex="0" :class="$style.container" @keydown.stop>
<AskAssistantChat
ref="assistantChatRef"
:user="user"
:messages="builderStore.chatMessages"
:streaming="builderStore.streaming"
@ -198,7 +257,7 @@ watch(currentRoute, () => {
:credits-quota="creditsQuota"
:credits-remaining="creditsRemaining"
:show-ask-owner-tooltip="showAskOwnerTooltip"
:inputPlaceholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
:input-placeholder="i18n.baseText('aiAssistant.builder.assistantPlaceholder')"
@close="emit('close')"
@message="onUserMessage"
@upgrade-click="() => goToUpgrade('ai-builder-sidebar', 'upgrade-builder')"
@ -208,6 +267,9 @@ watch(currentRoute, () => {
<template #header>
<slot name="header" />
</template>
<template #messagesFooter>
<ExecuteMessage v-if="showExecuteMessage" @workflow-executed="onWorkflowExecuted" />
</template>
<template #placeholder>
<N8nText :class="$style.topText">{{
i18n.baseText('aiAssistant.builder.assistantPlaceholder')

View File

@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { reactive, ref, nextTick } from 'vue';
import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import type { INodeUi } from '@/Interface';
import ExecuteMessage from './ExecuteMessage.vue';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useLogsStore } from '@/stores/logs.store';
const workflowValidationIssuesRef = ref<
Array<{ node: string; type: string; value: string | string[] }>
>([]);
const isWorkflowRunningRef = ref(false);
const executionWaitingForWebhookRef = ref(false);
const selectedTriggerNodeNameRef = ref<string | undefined>(undefined);
const workflowNodes = reactive<INodeUi[]>([
{
id: '1',
name: 'Start Trigger',
type: 'n8n-nodes-base.manualTrigger',
position: [0, 0],
parameters: {},
typeVersion: 1,
issues: {},
},
]);
const showMessageMock = vi.fn();
const runWorkflowMock = vi.fn();
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn(),
}),
useRoute: () => ({ params: {} }),
RouterLink: vi.fn(),
}));
vi.mock('@/composables/useRunWorkflow', () => ({
useRunWorkflow: () => ({
runWorkflow: runWorkflowMock,
}),
}));
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showMessage: showMessageMock,
}),
}));
const renderComponent = createComponentRenderer(ExecuteMessage);
vi.mock('./NodeIssueItem.vue', () => ({
default: {
template: '<li />',
},
}));
describe('ExecuteMessage', () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let renderExecuteMessage: () => ReturnType<ReturnType<typeof createComponentRenderer>>;
beforeEach(() => {
vi.clearAllMocks();
runWorkflowMock.mockReset();
showMessageMock.mockReset();
workflowValidationIssuesRef.value = [];
isWorkflowRunningRef.value = false;
executionWaitingForWebhookRef.value = false;
selectedTriggerNodeNameRef.value = undefined;
workflowNodes.splice(0, workflowNodes.length, {
id: '1',
name: 'Start Trigger',
type: 'n8n-nodes-base.manualTrigger',
position: [0, 0],
parameters: {},
typeVersion: 1,
issues: {},
});
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
nodeTypesStore = mockedStore(useNodeTypesStore);
logsStore = mockedStore(useLogsStore);
workflowsStore.workflow.nodes = workflowNodes as unknown as INodeUi[];
workflowsStore.workflow.connections = {} as never;
Object.defineProperty(workflowsStore, 'workflowValidationIssues', {
get: () => workflowValidationIssuesRef.value,
});
workflowsStore.formatIssueMessage = vi.fn((value: string | string[]) =>
Array.isArray(value) ? value.join(', ') : String(value),
);
Object.defineProperty(workflowsStore, 'isWorkflowRunning', {
get: () => isWorkflowRunningRef.value,
});
Object.defineProperty(workflowsStore, 'executionWaitingForWebhook', {
get: () => executionWaitingForWebhookRef.value,
set: (value: boolean) => {
executionWaitingForWebhookRef.value = value;
},
});
Object.defineProperty(workflowsStore, 'selectedTriggerNodeName', {
get: () => selectedTriggerNodeNameRef.value,
});
workflowsStore.setSelectedTriggerNodeName = vi.fn((name: string | undefined) => {
selectedTriggerNodeNameRef.value = name;
});
workflowsStore.getNodeByName = vi.fn(
(name: string) => workflowNodes.find((node) => node.name === name) ?? null,
);
logsStore.toggleOpen = vi.fn();
nodeTypesStore.isTriggerNode = vi
.fn()
.mockImplementation((type: string) => type.toLowerCase().includes('trigger'));
renderExecuteMessage = () => renderComponent({ pinia });
});
it('disables execution when validation issues exist', () => {
workflowValidationIssuesRef.value = [
{ node: 'Start Trigger', type: 'parameters', value: 'Missing field' },
];
const { getAllByTestId, getByText } = renderExecuteMessage();
expect(getByText('aiAssistant.builder.executeMessage.description')).toBeInTheDocument();
const button = getAllByTestId('execute-workflow-button')[0] as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it('runs workflow and emits completion event when execution finishes', async () => {
runWorkflowMock.mockImplementation(async () => {
isWorkflowRunningRef.value = true;
});
const { getAllByTestId, emitted } = renderExecuteMessage();
const button = getAllByTestId('execute-workflow-button')[0];
expect(button).not.toHaveAttribute('disabled');
await fireEvent.click(button);
await flushPromises();
isWorkflowRunningRef.value = false;
await nextTick();
expect(runWorkflowMock).toHaveBeenCalledWith({ triggerNode: 'Start Trigger' });
expect(emitted().workflowExecuted).toHaveLength(1);
});
it('opens chat logs and shows info toast for chat trigger nodes', async () => {
workflowNodes.push({
id: '2',
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
position: [0, 0],
parameters: {},
typeVersion: 1,
issues: {},
});
selectedTriggerNodeNameRef.value = 'Chat Trigger';
const { getAllByTestId, emitted } = renderExecuteMessage();
const button = getAllByTestId('execute-workflow-button')[0];
await fireEvent.click(button);
expect(runWorkflowMock).not.toHaveBeenCalled();
expect(showMessageMock).toHaveBeenCalledWith({
title: 'aiAssistant.builder.toast.title',
message: 'aiAssistant.builder.toast.description',
type: 'info',
});
expect(logsStore.toggleOpen).toHaveBeenCalledWith(true);
expect(emitted().workflowExecuted).toBeUndefined();
isWorkflowRunningRef.value = true;
await nextTick();
isWorkflowRunningRef.value = false;
await nextTick();
expect(emitted().workflowExecuted).toHaveLength(1);
});
it('emits completion after multiple run state toggles', async () => {
runWorkflowMock.mockImplementation(async () => {
isWorkflowRunningRef.value = true;
await nextTick();
isWorkflowRunningRef.value = false;
});
const { getAllByTestId, emitted } = renderExecuteMessage();
const button = getAllByTestId('execute-workflow-button')[0];
await fireEvent.click(button);
await flushPromises();
await nextTick();
// Toggle again manually to ensure watcher was cleaned up
isWorkflowRunningRef.value = false;
await nextTick();
expect(emitted().workflowExecuted).toHaveLength(1);
});
it('supports consecutive manual executions', async () => {
runWorkflowMock.mockImplementation(async () => {
isWorkflowRunningRef.value = true;
await nextTick();
isWorkflowRunningRef.value = false;
await nextTick();
});
const { getAllByTestId, emitted } = renderExecuteMessage();
const button = getAllByTestId('execute-workflow-button')[0];
await fireEvent.click(button);
await flushPromises();
await nextTick();
await fireEvent.click(button);
await flushPromises();
await nextTick();
expect(runWorkflowMock).toHaveBeenCalledTimes(2);
expect(emitted().workflowExecuted).toHaveLength(2);
});
});

View File

@ -0,0 +1,260 @@
<!-- eslint-disable import-x/extensions -->
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeUnmount, onMounted, ref, watch, type WatchStopHandle } from 'vue';
import { useRouter } from 'vue-router';
import NodeIssueItem from './NodeIssueItem.vue';
import CanvasRunWorkflowButton from '@/components/canvas/elements/buttons/CanvasRunWorkflowButton.vue';
import { useLogsStore } from '@/stores/logs.store';
import { isChatNode } from '@/utils/aiUtils';
import { useToast } from '@/composables/useToast';
import { N8nTooltip } from '@n8n/design-system';
import { nextTick } from 'vue';
interface Emits {
/** Emitted when workflow execution completes */
workflowExecuted: [];
}
const emit = defineEmits<Emits>();
// Initialize composables and stores
const router = useRouter();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const logsStore = useLogsStore();
const toast = useToast();
// Workflow execution composable
const { runWorkflow } = useRunWorkflow({ router });
let executionWatcherStop: WatchStopHandle | undefined;
const containerRef = ref<HTMLElement>();
const stopExecutionWatcher = () => {
if (executionWatcherStop) {
executionWatcherStop();
executionWatcherStop = undefined;
}
};
/**
* Sets up a watcher that fires exactly once per execution cycle.
*/
const ensureExecutionWatcher = () => {
if (executionWatcherStop) return;
let wasRunning = workflowsStore.isWorkflowRunning;
executionWatcherStop = watch(
() => workflowsStore.isWorkflowRunning,
(isRunning) => {
if (wasRunning && !isRunning) {
stopExecutionWatcher();
const wasCancelled = workflowsStore.workflowExecutionData?.status === 'canceled';
if (!wasCancelled) {
emit('workflowExecuted');
}
}
wasRunning = isRunning;
},
);
};
// Workflow validation from store
const workflowIssues = computed(() =>
workflowsStore.workflowValidationIssues.filter((issue) =>
['credentials', 'parameters'].includes(issue.type),
),
);
const hasValidationIssues = computed(() => workflowIssues.value.length > 0);
const formatIssueMessage = workflowsStore.formatIssueMessage;
const triggerNodes = computed(() =>
workflowsStore.workflow.nodes.filter((node) => nodeTypesStore.isTriggerNode(node.type)),
);
// Helper to get node type
function getNodeTypeByName(nodeName: string) {
const node = workflowsStore.workflow.nodes.find((n) => n.name === nodeName);
if (!node) return null;
return nodeTypesStore.getNodeType(node.type);
}
// Reactive workflow state
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
/**
* Determines available trigger nodes for execution
* Excludes trigger nodes when there are validation issues to prevent dropdown rendering
*/
const availableTriggerNodes = computed(() => (hasValidationIssues.value ? [] : triggerNodes.value));
const executeButtonTooltip = computed(() =>
hasValidationIssues.value
? i18n.baseText('aiAssistant.builder.executeMessage.validationTooltip')
: '',
);
async function onExecute() {
if (hasValidationIssues.value) {
return;
}
ensureExecutionWatcher();
const selectedTriggerNode =
workflowsStore.selectedTriggerNodeName ?? availableTriggerNodes.value[0]?.name;
const selectedTriggerNodeType = selectedTriggerNode
? workflowsStore.getNodeByName(selectedTriggerNode)
: null;
// If the selected trigger is a chat node, open logs panel instead of executing
// the execution will be handled by the chat node itself
if (selectedTriggerNodeType && isChatNode(selectedTriggerNodeType)) {
toast.showMessage({
title: i18n.baseText('aiAssistant.builder.toast.title'),
message: i18n.baseText('aiAssistant.builder.toast.description'),
type: 'info',
});
logsStore.toggleOpen(true);
return;
}
const runOptions: Parameters<typeof runWorkflow>[0] = {};
if (selectedTriggerNode) {
runOptions.triggerNode = selectedTriggerNode;
}
await runWorkflow(runOptions);
}
function scrollIntoView() {
containerRef.value?.scrollIntoView({ behavior: 'smooth' });
}
onMounted(scrollIntoView);
onBeforeUnmount(() => {
stopExecutionWatcher();
});
watch(workflowIssues, async () => {
await nextTick();
scrollIntoView();
});
</script>
<template>
<div
ref="containerRef"
:class="$style.container"
role="region"
aria-label="Workflow execution panel"
>
<!-- Validation Issues Section -->
<template v-if="hasValidationIssues">
<p :class="$style.description">
{{ i18n.baseText('aiAssistant.builder.executeMessage.description') }}
</p>
<TransitionGroup
name="fade"
tag="ul"
:class="$style.issuesList"
role="list"
aria-label="Workflow validation issues"
>
<NodeIssueItem
v-for="issue in workflowIssues"
:key="`${formatIssueMessage(issue.value)}_${issue.node}`"
:issue="issue"
:get-node-type="getNodeTypeByName"
:format-issue-message="formatIssueMessage"
/>
</TransitionGroup>
</template>
<!-- No Issues Section -->
<template v-else>
<p :class="$style.noIssuesMessage">
{{ i18n.baseText('aiAssistant.builder.executeMessage.noIssues') }}
</p>
</template>
<!-- Execution Button -->
<N8nTooltip :disabled="!hasValidationIssues" :content="executeButtonTooltip" placement="left">
<CanvasRunWorkflowButton
:class="$style.runButton"
:disabled="hasValidationIssues"
:waiting-for-webhook="isExecutionWaitingForWebhook"
:hide-tooltip="true"
:label="i18n.baseText('aiAssistant.builder.executeMessage.execute')"
:executing="isWorkflowRunning"
:include-chat-trigger="true"
size="medium"
:trigger-nodes="availableTriggerNodes"
:get-node-type="nodeTypesStore.getNodeType"
:selected-trigger-node-name="workflowsStore.selectedTriggerNodeName"
@execute="onExecute"
@select-trigger-node="workflowsStore.setSelectedTriggerNodeName"
/>
</N8nTooltip>
</div>
</template>
<style lang="scss" scoped>
/* Fade transition animations for issue list */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
padding: var(--spacing-xs);
gap: var(--spacing-xs);
background-color: var(--color-background-xlight);
border: var(--border-base);
border-radius: var(--border-radius-large);
line-height: var(--font-line-height-loose);
position: relative;
font-size: var(--font-size-2xs);
}
.description {
margin: 0;
color: var(--color-text-dark);
line-height: var(--font-line-height-regular);
}
.noIssuesMessage {
margin: 0;
color: var(--color-text-dark);
}
.issuesList {
margin: 0;
padding: 0;
position: relative;
}
.runButton {
align-self: stretch;
}
</style>

View File

@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import NodeIssueItem from './NodeIssueItem.vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
vi.mock('@/components/NodeIcon.vue', () => ({
default: {
template: '<span />',
},
}));
const renderComponent = createComponentRenderer(NodeIssueItem);
describe('NodeIssueItem', () => {
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
ndvStore = mockedStore(useNDVStore);
ndvStore.setActiveNodeName = vi.fn();
});
it('renders issue information using provided formatter', () => {
const { getByText } = renderComponent({
pinia,
props: {
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
getNodeType: vi.fn(),
formatIssueMessage: (value: string | string[]) =>
Array.isArray(value) ? value.join(', ') : value,
},
});
expect(getByText('Linear:')).toBeInTheDocument();
expect(getByText('Missing API key')).toBeInTheDocument();
});
it('opens NDV on edit button click', async () => {
const nodeType = {
name: 'n8n-nodes-base.linear',
displayName: 'Linear',
} as INodeTypeDescription;
const { getByLabelText } = renderComponent({
pinia,
props: {
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
getNodeType: vi.fn(() => nodeType),
formatIssueMessage: (value: string) => value,
},
});
await fireEvent.click(getByLabelText('Edit Linear node'));
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Linear', 'other');
});
});

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import type { INodeTypeDescription } from 'n8n-workflow';
// Types for node issues
interface WorkflowNodeIssue {
node: string;
type: string;
value: string | string[];
}
import { useNDVStore } from '@/stores/ndv.store';
interface Props {
/** The node issue to display */
issue: WorkflowNodeIssue;
/** Function to get node type information */
getNodeType: (nodeName: string) => INodeTypeDescription | null;
/** Function to format issue messages */
formatIssueMessage: (value: WorkflowNodeIssue['value']) => string;
}
const props = defineProps<Props>();
const ndvStore = useNDVStore();
function handleEditClick() {
ndvStore.setActiveNodeName(props.issue.node, 'other');
}
</script>
<template>
<li
:class="$style.nodeIssue"
role="listitem"
:aria-label="`Edit ${issue.node} node`"
@click="handleEditClick"
>
<!-- Node icon with tooltip -->
<NodeIcon
:node-type="getNodeType(issue.node)"
:size="14"
:shrink="false"
:show-tooltip="true"
tooltip-position="left"
:class="$style.nodeIcon"
:aria-label="`${issue.node} node`"
/>
<!-- Issue message -->
<div :class="$style.issueMessage" :aria-label="`Issue: ${formatIssueMessage(issue.value)}`">
<span :class="$style.nodeName">{{ issue.node }}:</span>
{{ formatIssueMessage(issue.value) }}
</div>
<!-- Edit button -->
<N8nIcon size="large" icon="pencil" />
</li>
</template>
<style lang="scss" module>
.nodeIssue {
list-style: none;
display: flex;
align-items: center;
padding: var(--spacing-3xs) 0;
border-bottom: 1px solid var(--color-foreground-light);
cursor: pointer;
&:hover {
color: var(--color-primary);
}
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
}
}
.nodeIcon {
margin-right: var(--spacing-2xs);
margin-top: var(--spacing-4xs);
flex-shrink: 0;
align-self: flex-start;
}
.nodeName {
font-weight: var(--font-weight-bold);
flex-shrink: 0;
}
.issueMessage {
flex: 1;
padding-right: var(--spacing-xs);
line-height: var(--font-line-height-regular);
}
.editButton {
--button-border-color: transparent;
margin-left: auto;
flex-shrink: 0;
align-self: center;
}
</style>

View File

@ -22,17 +22,23 @@ const props = defineProps<{
waitingForWebhook?: boolean;
executing?: boolean;
disabled?: boolean;
hideTooltip?: boolean;
label?: string;
size?: 'small' | 'medium' | 'large';
includeChatTrigger?: boolean;
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
}>();
const i18n = useI18n();
const selectableTriggerNodes = computed(() =>
props.triggerNodes.filter((node) => !node.disabled && !isChatNode(node)),
props.triggerNodes.filter(
(node) => !node.disabled && (props.includeChatTrigger ? true : !isChatNode(node)),
),
);
const label = computed(() => {
if (!props.executing) {
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
return props.label ?? i18n.baseText('nodeView.runButtonText.executeWorkflow');
}
if (props.waitingForWebhook) {
@ -43,7 +49,7 @@ const label = computed(() => {
});
const actions = computed(() =>
props.triggerNodes
.filter((node) => !isChatNode(node))
.filter((node) => (props.includeChatTrigger ? true : !isChatNode(node)))
.toSorted((a, b) => {
const [aX, aY] = a.position;
const [bX, bY] = b.position;
@ -77,13 +83,13 @@ function getNodeTypeByName(name: string): INodeTypeDescription | null {
<KeyboardShortcutTooltip
:label="label"
:shortcut="{ metaKey: true, keys: ['↵'] }"
:disabled="executing"
:disabled="executing || hideTooltip"
>
<N8nButton
:class="$style.button"
:loading="executing"
:disabled="disabled"
size="large"
:size="size ?? 'large'"
icon="flask-conical"
type="primary"
data-test-id="execute-workflow-button"

View File

@ -2,7 +2,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { ENABLED_VIEWS, useBuilderStore } from '@/stores/builder.store';
import { usePostHog } from './posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -20,12 +21,20 @@ import * as telemetryModule from '@/composables/useTelemetry';
import type { Telemetry } from '@/plugins/telemetry';
import type { ChatUI } from '@n8n/design-system/types/assistant';
import { DEFAULT_CHAT_WIDTH, MAX_CHAT_WIDTH, MIN_CHAT_WIDTH } from './assistant.store';
import type { INodeTypeDescription } from 'n8n-workflow';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
// Mock useI18n to return the keys instead of translations
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
i18n: {
baseText: (key: string) => key,
},
}));
// Mock useToast
@ -35,31 +44,16 @@ vi.mock('@/composables/useToast', () => ({
}),
}));
// Mock the workflows store
const mockSetWorkflowName = vi.fn();
const mockRemoveAllConnections = vi.fn();
const mockRemoveAllNodes = vi.fn();
const mockWorkflow = {
name: DEFAULT_NEW_WORKFLOW_NAME,
nodes: [],
connections: {},
};
vi.mock('./workflows.store', () => ({
useWorkflowsStore: vi.fn(() => ({
workflow: mockWorkflow,
workflowId: 'test-workflow-id',
allNodes: [],
nodesByName: {},
workflowExecutionData: null,
setWorkflowName: mockSetWorkflowName,
removeAllConnections: mockRemoveAllConnections,
removeAllNodes: mockRemoveAllNodes,
})),
}));
let settingsStore: ReturnType<typeof useSettingsStore>;
let posthogStore: ReturnType<typeof usePostHog>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
let setWorkflowNameSpy: ReturnType<typeof vi.fn>;
let getNodeTypeSpy: ReturnType<typeof vi.fn>;
let getCredentialsByTypeSpy: ReturnType<typeof vi.fn>;
const apiSpy = vi.spyOn(chatAPI, 'chatWithBuilder');
@ -96,7 +90,8 @@ vi.mock('vue-router', () => ({
describe('AI Builder store', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
settingsStore = useSettingsStore();
settingsStore.setSettings(
merge({}, defaultSettings, {
@ -110,14 +105,33 @@ describe('AI Builder store', () => {
posthogStore = usePostHog();
posthogStore.init();
track.mockReset();
// Reset workflow store mocks
mockSetWorkflowName.mockReset();
mockRemoveAllConnections.mockReset();
mockRemoveAllNodes.mockReset();
// Reset workflow to default state
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
mockWorkflow.nodes = [];
mockWorkflow.connections = {};
workflowsStore = mockedStore(useWorkflowsStore);
nodeTypesStore = mockedStore(useNodeTypesStore);
credentialsStore = mockedStore(useCredentialsStore);
workflowsStore.workflowId = 'test-workflow-id';
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
workflowsStore.workflow.nodes = [];
workflowsStore.workflow.connections = {};
workflowsStore.allNodes = [];
workflowsStore.nodesByName = {};
workflowsStore.workflowExecutionData = null;
setWorkflowNameSpy = workflowsStore.setWorkflowName.mockImplementation(({ newName }) => {
workflowsStore.workflow.name = newName;
});
getNodeTypeSpy = vi.fn();
vi.spyOn(nodeTypesStore, 'getNodeType', 'get').mockReturnValue(getNodeTypeSpy);
getCredentialsByTypeSpy = vi.fn().mockReturnValue([]);
vi.spyOn(credentialsStore, 'getCredentialsByType', 'get').mockReturnValue(
getCredentialsByTypeSpy,
);
vi.spyOn(credentialsStore, 'getCredentialTypeByName', 'get').mockReturnValue(
vi.fn().mockReturnValue(undefined),
);
});
afterEach(() => {
@ -855,7 +869,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@ -879,7 +893,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was called with the generated name
expect(mockSetWorkflowName).toHaveBeenCalledWith({
expect(setWorkflowNameSpy).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
@ -892,7 +906,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Set a custom workflow name (not the default)
mockWorkflow.name = 'My Custom Workflow';
workflowsStore.workflow.name = 'My Custom Workflow';
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@ -916,7 +930,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should NOT apply generated workflow name when not initial generation', () => {
@ -926,7 +940,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = false;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@ -950,7 +964,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should handle workflow updates without name property', () => {
@ -960,7 +974,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// Create workflow JSON without a name property
const workflowJson = JSON.stringify({
@ -983,7 +997,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName was NOT called
expect(mockSetWorkflowName).not.toHaveBeenCalled();
expect(setWorkflowNameSpy).not.toHaveBeenCalled();
});
it('should handle workflow names that start with but are not exactly the default name', () => {
@ -993,7 +1007,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Set workflow name that starts with default but has more text
mockWorkflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
workflowsStore.workflow.name = `${DEFAULT_NEW_WORKFLOW_NAME} - Copy`;
// Create workflow JSON with a generated name
const workflowJson = JSON.stringify({
@ -1017,7 +1031,7 @@ describe('AI Builder store', () => {
expect(result.success).toBe(true);
// Verify setWorkflowName WAS called because the name starts with default
expect(mockSetWorkflowName).toHaveBeenCalledWith({
expect(setWorkflowNameSpy).toHaveBeenCalledWith({
newName: 'Generated Workflow Name for Email Processing',
setStateDirty: false,
});
@ -1047,7 +1061,7 @@ describe('AI Builder store', () => {
builderStore.initialGeneration = true;
// Ensure workflow has default name
mockWorkflow.name = DEFAULT_NEW_WORKFLOW_NAME;
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
// First update with name
const workflowJson1 = JSON.stringify({
@ -1057,7 +1071,7 @@ describe('AI Builder store', () => {
});
builderStore.applyWorkflowUpdate(workflowJson1);
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
// The flag should still be true for subsequent updates in the same generation
expect(builderStore.initialGeneration).toBe(true);
@ -1079,7 +1093,106 @@ describe('AI Builder store', () => {
builderStore.applyWorkflowUpdate(workflowJson2);
// Should not call setWorkflowName again
expect(mockSetWorkflowName).toHaveBeenCalledTimes(1);
expect(setWorkflowNameSpy).toHaveBeenCalledTimes(1);
});
describe('applyWorkflowUpdate credential defaults', () => {
const createTestNodeType = (): INodeTypeDescription => ({
displayName: 'Test Node',
name: 'n8n-nodes-base.test',
description: 'Test node',
group: ['trigger'],
version: 1,
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'API Key',
value: 'apiKey',
},
],
default: 'apiKey',
required: true,
},
],
credentials: [
{
name: 'testApi',
required: true,
displayOptions: {
show: {
authentication: ['apiKey'],
},
},
},
],
});
it('assigns default credentials when available', () => {
const builderStore = useBuilderStore();
getNodeTypeSpy.mockReturnValue(createTestNodeType());
getCredentialsByTypeSpy.mockReturnValue([
{ id: 'cred-id', name: 'API Credential', type: 'testApi' },
]);
const workflowJson = JSON.stringify({
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.test',
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const result = builderStore.applyWorkflowUpdate(workflowJson);
expect(result.success).toBe(true);
const [node] = result.workflowData?.nodes ?? [];
expect(node.credentials).toEqual({
testApi: { id: 'cred-id', name: 'API Credential' },
});
expect(node.parameters.authentication).toBe('apiKey');
});
it('keeps existing credentials untouched', () => {
const builderStore = useBuilderStore();
getNodeTypeSpy.mockReturnValue(createTestNodeType());
getCredentialsByTypeSpy.mockReturnValue([
{ id: 'cred-id', name: 'API Credential', type: 'testApi' },
]);
const workflowJson = JSON.stringify({
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.test',
position: [0, 0],
parameters: { authentication: 'apiKey' },
credentials: {
testApi: { id: 'existing', name: 'Existing Credential' },
},
},
],
connections: {},
});
const result = builderStore.applyWorkflowUpdate(workflowJson);
expect(result.success).toBe(true);
const [node] = result.workflowData?.nodes ?? [];
expect(node.credentials).toEqual({
testApi: { id: 'existing', name: 'Existing Credential' },
});
});
});
});

View File

@ -28,6 +28,9 @@ import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import pick from 'lodash/pick';
import { jsonParse } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeTypesStore } from './nodeTypes.store';
import { useCredentialsStore } from './credentials.store';
import { getAuthTypeForNodeCredential, getMainAuthField } from '@/utils/nodeTypesUtils';
const INFINITE_CREDITS = -1;
export const ENABLED_VIEWS = [...EDITABLE_CANVAS_VIEWS];
@ -49,6 +52,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const route = useRoute();
const locale = useI18n();
const telemetry = useTelemetry();
@ -274,12 +280,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
source?: 'chat' | 'canvas';
quickReplyType?: string;
initialGeneration?: boolean;
type?: 'message' | 'execution';
errorMessage?: string;
errorNodeType?: string;
executionStatus?: string;
}) {
if (streaming.value) {
return;
}
const { text, source = 'chat', quickReplyType } = options;
const {
text,
source = 'chat',
quickReplyType,
errorMessage,
type = 'message',
errorNodeType,
executionStatus,
} = options;
// Set initial generation flag if provided
if (options.initialGeneration !== undefined) {
@ -288,13 +306,24 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const messageId = generateMessageId();
const currentWorkflowJson = getWorkflowSnapshot();
telemetry.track('User submitted builder message', {
const trackingPayload: Record<string, string> = {
source,
message: text,
session_id: trackingSessionId.value,
start_workflow_json: currentWorkflowJson,
workflow_id: workflowsStore.workflowId,
});
type,
};
if (type === 'execution') {
trackingPayload.execution_status = executionStatus ?? '';
if (executionStatus === 'error') {
trackingPayload.error_message = errorMessage ?? '';
trackingPayload.error_node_type = errorNodeType ?? '';
}
}
telemetry.track('User submitted builder message', trackingPayload);
prepareForStreaming(text, messageId);
@ -410,6 +439,48 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
};
}
function setDefaultNodesCredentials(workflowData: WorkflowDataUpdate) {
// Set default credentials for new nodes if available
workflowData.nodes?.forEach((node) => {
const hasCredentials = node.credentials && Object.keys(node.credentials).length > 0;
if (hasCredentials) {
return;
}
const nodeType = nodeTypesStore.getNodeType(node.type);
if (!nodeType?.credentials) {
return;
}
// Try to find and set the first available credential
for (const credentialConfig of nodeType.credentials) {
const credentials = credentialsStore.getCredentialsByType(credentialConfig.name);
// No credentials of this type exist, try the next one
if (!credentials || credentials.length === 0) {
continue;
}
// Found valid credentials - set them and exit the loop
const credential = credentials[0];
node.credentials = {
[credential.type]: {
id: credential.id,
name: credential.name,
},
};
const authField = getMainAuthField(nodeType);
const authType = getAuthTypeForNodeCredential(nodeType, credentialConfig);
if (authField && authType) {
node.parameters[authField.name] = authType.value;
}
break; // Exit loop after setting the first valid credential
}
});
}
function applyWorkflowUpdate(workflowJson: string) {
let workflowData: WorkflowDataUpdate;
try {
@ -455,6 +526,8 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
});
}
setDefaultNodesCredentials(workflowData);
return {
success: true,
workflowData,

View File

@ -15,6 +15,7 @@ import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type {
IPinData,
IConnection,
IConnections,
INodeExecutionData,
INode,
INodeTypeDescription,
@ -150,6 +151,92 @@ describe('useWorkflowsStore', () => {
});
});
describe('workflowValidationIssues', () => {
it('collects issues only from connected, enabled nodes', () => {
const connections: IConnections = {
Start: {
main: [
[
{
node: 'Fetch',
type: 'main',
index: 0,
},
],
],
},
};
workflowsStore.workflow.nodes = [
{
id: 'start',
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
parameters: {},
position: [0, 0],
},
{
id: 'fetch',
name: 'Fetch',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
parameters: {},
issues: {
parameters: {
url: ['Missing URL', 'Invalid URL.'],
},
credentials: {
httpBasicAuth: ['Credentials not set'],
},
},
position: [300, 0],
},
{
id: 'orphan',
name: 'Disconnected',
type: 'n8n-nodes-base.set',
typeVersion: 1,
parameters: {},
issues: {
parameters: { field: ['Should be ignored'] },
},
position: [0, 400],
},
{
id: 'disabled',
name: 'Disabled Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
disabled: true,
parameters: {},
issues: {
parameters: { field: ['Disabled issue'] },
},
position: [0, 600],
},
];
workflowsStore.workflow.connections = connections;
const issues = workflowsStore.workflowValidationIssues;
expect(issues).toEqual([
{ node: 'Fetch', type: 'parameters', value: ['Missing URL', 'Invalid URL.'] },
{ node: 'Fetch', type: 'credentials', value: ['Credentials not set'] },
]);
});
});
describe('formatIssueMessage', () => {
it('joins array entries and trims trailing period', () => {
const message = workflowsStore.formatIssueMessage(['Missing URL', 'Invalid value.']);
expect(message).toBe('Missing URL, Invalid value');
});
it('returns string representation for non-array values', () => {
expect(workflowsStore.formatIssueMessage('Simple issue.')).toBe('Simple issue.');
});
});
describe('allWorkflows', () => {
it('should return sorted workflows by name', () => {
workflowsStore.setWorkflows([

View File

@ -277,6 +277,66 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}),
);
/**
* Get detailed validation issues for all connected, enabled nodes
*/
const workflowValidationIssues = computed(() => {
const issues: Array<{
node: string;
type: string;
value: string | string[];
}> = [];
const isStringOrStringArray = (value: unknown): value is string | string[] =>
typeof value === 'string' || Array.isArray(value);
workflow.value.nodes.forEach((node) => {
if (!node.issues || node.disabled) return;
const isConnected =
Object.keys(outgoingConnectionsByNodeName(node.name)).length > 0 ||
Object.keys(incomingConnectionsByNodeName(node.name)).length > 0;
if (!isConnected) return;
Object.entries(node.issues).forEach(([issueType, issueValue]) => {
if (!issueValue) return;
if (typeof issueValue === 'object' && !Array.isArray(issueValue)) {
// Handle nested issues (parameters, credentials)
Object.entries(issueValue).forEach(([_key, value]) => {
if (value) {
issues.push({
node: node.name,
type: issueType,
value,
});
}
});
} else {
// Handle direct issues
issues.push({
node: node.name,
type: issueType,
value: isStringOrStringArray(issueValue) ? issueValue : String(issueValue),
});
}
});
});
return issues;
});
/**
* Format issue message for display
*/
function formatIssueMessage(issue: string | string[]): string {
if (Array.isArray(issue)) {
return issue.join(', ').replace(/\.$/, '');
}
return String(issue);
}
const pinnedWorkflowData = computed(() => workflow.value.pinData);
const executedNode = computed(() => workflowExecutionData.value?.executedNode);
@ -2013,6 +2073,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
canvasNames,
nodesByName,
nodesIssuesExist,
workflowValidationIssues,
formatIssueMessage,
pinnedWorkflowData,
executedNode,
getAllLoadedFinishedExecutions,

View File

@ -1124,22 +1124,28 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: Workf
async function onImportWorkflowDataEvent(data: IDataObject) {
const workflowData = data.data as WorkflowDataUpdate;
const trackEvents = typeof data.trackEvents === 'boolean' ? data.trackEvents : undefined;
await importWorkflowData(workflowData, 'file', {
viewport: viewportBoundaries.value,
regenerateIds: data.regenerateIds === true || data.regenerateIds === undefined,
trackEvents,
});
await nextTick();
fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
if (data.tidyUp) {
const nodesIdsToTidyUp = data.nodesIdsToTidyUp as string[];
setTimeout(() => {
setTimeout(async () => {
canvasEventBus.emit('tidyUp', {
source: 'import-workflow-data',
nodeIdsFilter: nodesIdsToTidyUp,
trackEvents,
});
await nextTick();
fitView();
}, 0);
}
}