fix(editor): Show only error title and 'Open errored node' button; hide 'Ask Assistant' in root for sub-node errors (#11573)

This commit is contained in:
Ivan Atanasov 2024-11-07 10:14:49 +01:00 committed by GitHub
parent 40c88822ac
commit 8cba100488
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 56 deletions

View file

@ -1,38 +1,61 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import NodeErrorView from '@/components/Error/NodeErrorView.vue'; import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { type INode } from 'n8n-workflow'; import type { NodeError } from 'n8n-workflow';
import { useAssistantStore } from '@/stores/assistant.store'; import { useAssistantStore } from '@/stores/assistant.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mockedStore } from '@/__tests__/utils';
import userEvent from '@testing-library/user-event';
import { useNDVStore } from '@/stores/ndv.store';
const DEFAULT_SETUP = { const renderComponent = createComponentRenderer(NodeErrorView);
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
},
}),
};
const renderComponent = createComponentRenderer(NodeErrorView, DEFAULT_SETUP); let mockAiAssistantStore: ReturnType<typeof mockedStore<typeof useAssistantStore>>;
let mockNodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let mockNdvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
describe('NodeErrorView.vue', () => { describe('NodeErrorView.vue', () => {
let mockNode: INode; let error: NodeError;
afterEach(() => {
mockNode = { beforeEach(() => {
parameters: { createTestingPinia();
mode: 'runOnceForAllItems',
language: 'javaScript', mockAiAssistantStore = mockedStore(useAssistantStore);
jsCode: 'cons error = 9;', mockNodeTypeStore = mockedStore(useNodeTypesStore);
notice: '', mockNdvStore = mockedStore(useNDVStore);
//@ts-expect-error
error = {
name: 'NodeOperationError',
message: 'Test error message',
description: 'Test error description',
context: {
descriptionKey: 'noInputConnection',
nodeCause: 'Test node cause',
runIndex: '1',
itemIndex: '2',
parameter: 'testParameter',
data: { key: 'value' },
causeDetailed: 'Detailed cause',
}, },
id: 'd1ce5dc9-f9ae-4ac6-84e5-0696ba175dd9', node: {
name: 'Code', parameters: {
type: 'n8n-nodes-base.code', mode: 'runOnceForAllItems',
typeVersion: 2, language: 'javaScript',
position: [940, 240], jsCode: 'cons error = 9;',
notice: '',
},
id: 'd1ce5dc9-f9ae-4ac6-84e5-0696ba175dd9',
name: 'ErrorCode',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [940, 240],
},
stack: 'Test stack trace',
}; };
});
afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -40,7 +63,7 @@ describe('NodeErrorView.vue', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { props: {
error: { error: {
node: mockNode, node: error.node,
messages: ['Unexpected identifier [line 1]'], messages: ['Unexpected identifier [line 1]'],
}, },
}, },
@ -55,7 +78,7 @@ describe('NodeErrorView.vue', () => {
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { props: {
error: { error: {
node: mockNode, node: error.node,
message: 'Unexpected identifier [line 1]', message: 'Unexpected identifier [line 1]',
}, },
}, },
@ -67,24 +90,20 @@ describe('NodeErrorView.vue', () => {
}); });
it('should not render AI assistant button when error happens in deprecated function node', async () => { it('should not render AI assistant button when error happens in deprecated function node', async () => {
const aiAssistantStore = useAssistantStore(DEFAULT_SETUP.pinia);
const nodeTypeStore = useNodeTypesStore(DEFAULT_SETUP.pinia);
//@ts-expect-error //@ts-expect-error
nodeTypeStore.getNodeType = vi.fn(() => ({ mockNodeTypeStore.getNodeType = vi.fn(() => ({
type: 'n8n-nodes-base.function', type: 'n8n-nodes-base.function',
typeVersion: 1, typeVersion: 1,
hidden: true, hidden: true,
})); }));
//@ts-expect-error mockAiAssistantStore.canShowAssistantButtonsOnCanvas = true;
aiAssistantStore.canShowAssistantButtonsOnCanvas = true;
const { queryByTestId } = renderComponent({ const { queryByTestId } = renderComponent({
props: { props: {
error: { error: {
node: { node: {
...mockNode, ...error.node,
type: 'n8n-nodes-base.function', type: 'n8n-nodes-base.function',
typeVersion: 1, typeVersion: 1,
}, },
@ -96,4 +115,73 @@ describe('NodeErrorView.vue', () => {
expect(aiAssistantButton).toBeNull(); expect(aiAssistantButton).toBeNull();
}); });
it('renders error message', () => {
const { getByTestId } = renderComponent({
props: { error },
});
expect(getByTestId('node-error-message').textContent).toContain('Test error message');
});
it('renders error description', () => {
const { getByTestId } = renderComponent({
props: { error },
});
expect(getByTestId('node-error-description').innerHTML).toContain(
'This node has no input data. Please make sure this node is connected to another node.',
);
});
it('renders stack trace', () => {
const { getByText } = renderComponent({
props: { error },
});
expect(getByText('Test stack trace')).toBeTruthy();
});
it('renders open node button when the error is in sub node', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
error: {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
},
},
});
expect(getByTestId('node-error-view-open-node-button')).toHaveTextContent('Open errored node');
expect(queryByTestId('ask-assistant-button')).not.toBeInTheDocument();
});
it('does not renders open node button when the error is in sub node', () => {
mockAiAssistantStore.canShowAssistantButtonsOnCanvas = true;
const { getByTestId, queryByTestId } = renderComponent({
props: {
error,
},
});
expect(queryByTestId('node-error-view-open-node-button')).not.toBeInTheDocument();
expect(getByTestId('ask-assistant-button')).toBeInTheDocument();
});
it('open error node details when open error node is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
props: {
error: {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
},
},
});
await userEvent.click(getByTestId('node-error-view-open-node-button'));
expect(emitted().click).toHaveLength(1);
expect(mockNdvStore.activeNodeName).toBe(error.node.name);
});
}); });

View file

@ -117,7 +117,7 @@ const prepareRawMessages = computed(() => {
}); });
const isAskAssistantAvailable = computed(() => { const isAskAssistantAvailable = computed(() => {
if (!node.value) { if (!node.value || isSubNodeError.value) {
return false; return false;
} }
const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type); const isCustomNode = node.value.type === undefined || isCommunityPackageName(node.value.type);
@ -132,6 +132,13 @@ const assistantAlreadyAsked = computed(() => {
}); });
}); });
const isSubNodeError = computed(() => {
return (
props.error.name === 'NodeOperationError' &&
(props.error as NodeOperationError).functionality === 'configuration-node'
);
});
function nodeVersionTag(nodeType: NodeError['node']): string { function nodeVersionTag(nodeType: NodeError['node']): string {
if (!nodeType || ('hidden' in nodeType && nodeType.hidden)) { if (!nodeType || ('hidden' in nodeType && nodeType.hidden)) {
return i18n.baseText('nodeSettings.deprecated'); return i18n.baseText('nodeSettings.deprecated');
@ -153,19 +160,6 @@ function prepareDescription(description: string): string {
} }
function getErrorDescription(): string { function getErrorDescription(): string {
const isSubNodeError =
props.error.name === 'NodeOperationError' &&
(props.error as NodeOperationError).functionality === 'configuration-node';
if (isSubNodeError) {
return prepareDescription(
props.error.description +
i18n.baseText('pushConnection.executionError.openNode', {
interpolate: { node: props.error.node.name },
}),
);
}
if (props.error.context?.descriptionKey) { if (props.error.context?.descriptionKey) {
const interpolate = { const interpolate = {
nodeCause: props.error.context.nodeCause as string, nodeCause: props.error.context.nodeCause as string,
@ -205,13 +199,10 @@ function addItemIndexSuffix(message: string): string {
function getErrorMessage(): string { function getErrorMessage(): string {
let message = ''; let message = '';
const isSubNodeError =
props.error.name === 'NodeOperationError' &&
(props.error as NodeOperationError).functionality === 'configuration-node';
const isNonEmptyString = (value?: unknown): value is string => const isNonEmptyString = (value?: unknown): value is string =>
!!value && typeof value === 'string'; !!value && typeof value === 'string';
if (isSubNodeError) { if (isSubNodeError.value) {
message = i18n.baseText('nodeErrorView.errorSubNode', { message = i18n.baseText('nodeErrorView.errorSubNode', {
interpolate: { node: props.error.node.name }, interpolate: { node: props.error.node.name },
}); });
@ -390,6 +381,10 @@ function nodeIsHidden() {
return nodeType?.hidden ?? false; return nodeType?.hidden ?? false;
} }
const onOpenErrorNodeDetailClick = () => {
ndvStore.activeNodeName = props.error.node.name;
};
async function onAskAssistantClick() { async function onAskAssistantClick() {
const { message, lineNumber, description } = props.error; const { message, lineNumber, description } = props.error;
const sessionInProgress = !assistantStore.isSessionEnded; const sessionInProgress = !assistantStore.isSessionEnded;
@ -428,14 +423,25 @@ async function onAskAssistantClick() {
</div> </div>
</div> </div>
<div <div
v-if="error.description || error.context?.descriptionKey" v-if="(error.description || error.context?.descriptionKey) && !isSubNodeError"
data-test-id="node-error-description" data-test-id="node-error-description"
class="node-error-view__header-description" class="node-error-view__header-description"
v-n8n-html="getErrorDescription()" v-n8n-html="getErrorDescription()"
></div> ></div>
<div v-if="isSubNodeError">
<n8n-button
icon="arrow-right"
type="secondary"
:label="i18n.baseText('pushConnection.executionError.openNode')"
class="node-error-view__button"
data-test-id="node-error-view-open-node-button"
@click="onOpenErrorNodeDetailClick"
/>
</div>
<div <div
v-if="isAskAssistantAvailable" v-if="isAskAssistantAvailable"
class="node-error-view__assistant-button" class="node-error-view__button"
data-test-id="node-error-view-ask-assistant-button" data-test-id="node-error-view-ask-assistant-button"
> >
<InlineAskAssistantButton :asked="assistantAlreadyAsked" @click="onAskAssistantClick" /> <InlineAskAssistantButton :asked="assistantAlreadyAsked" @click="onAskAssistantClick" />
@ -696,9 +702,14 @@ async function onAskAssistantClick() {
} }
} }
&__assistant-button { &__button {
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
flex-direction: row-reverse;
span {
margin-right: var(--spacing-5xs);
margin-left: var(--spacing-5xs);
}
} }
&__debugging { &__debugging {
@ -831,7 +842,7 @@ async function onAskAssistantClick() {
} }
} }
.node-error-view__assistant-button { .node-error-view__button {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
</style> </style>

View file

@ -1498,7 +1498,7 @@
"pushConnection.executionFailed": "Execution failed", "pushConnection.executionFailed": "Execution failed",
"pushConnection.executionFailed.message": "There might not be enough memory to finish the execution. Tips for avoiding this <a target=\"_blank\" href=\"https://docs.n8n.io/flow-logic/error-handling/memory-errors/\">here</a>", "pushConnection.executionFailed.message": "There might not be enough memory to finish the execution. Tips for avoiding this <a target=\"_blank\" href=\"https://docs.n8n.io/flow-logic/error-handling/memory-errors/\">here</a>",
"pushConnection.executionError": "There was a problem executing the workflow{error}", "pushConnection.executionError": "There was a problem executing the workflow{error}",
"pushConnection.executionError.openNode": " <a data-action='openNodeDetail' data-action-parameter-node='{node}'>Open node</a>", "pushConnection.executionError.openNode": "Open errored node",
"pushConnection.executionError.details": "<br /><strong>{details}</strong>", "pushConnection.executionError.details": "<br /><strong>{details}</strong>",
"prompts.productTeamMessage": "Our product team will get in touch personally", "prompts.productTeamMessage": "Our product team will get in touch personally",
"prompts.npsSurvey.recommendationQuestion": "How likely are you to recommend n8n to a friend or colleague?", "prompts.npsSurvey.recommendationQuestion": "How likely are you to recommend n8n to a friend or colleague?",