From 37aee61ff412d2b0b7577dcf569df652ea81689e Mon Sep 17 00:00:00 2001 From: Daria Date: Fri, 21 Aug 2026 12:36:31 +0000 Subject: [PATCH] fix(editor): Group output panel warnings to keep it usable (#36619) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../__snapshots__/InputPanel.test.ts.snap | 13 +- .../ndv/runData/components/RunData.test.ts | 19 +- .../ndv/runData/components/RunData.vue | 35 ++- .../runData/components/RunDataHints.test.ts | 268 ++++++++++++++++++ .../ndv/runData/components/RunDataHints.vue | 252 ++++++++++++++++ .../Transform/Aggregate/Aggregate.node.ts | 7 +- .../Transform/SplitOut/test/utils.test.ts | 5 + .../nodes/Transform/SplitOut/utils.ts | 7 +- .../nodes-base/nodes/Transform/utils/utils.ts | 15 + packages/workflow/src/interfaces.ts | 17 ++ 11 files changed, 617 insertions(+), 22 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataHints.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataHints.vue diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index aa026746ad9..78260627092 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4398,6 +4398,7 @@ "ndv.nodeHints.executeOnce": "This node will execute only once, no matter how many input items there are", "ndv.nodeHints.retryOnFail": "This node will automatically retry if it fails", "ndv.nodeHints.continueOnError": "Execution will continue even if the node fails", + "ndv.nodeHints.repeatedCount": "Occurred {count} times", "updatesPanel.andIs": "and is", "updatesPanel.behindTheLatest": "behind the latest and greatest n8n", "updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version", diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap b/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap index a12f55a23eb..b686e7b542b 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/__snapshots__/InputPanel.test.ts.snap @@ -276,10 +276,19 @@ exports[`InputPanel > should render 1`] = ` + +
- - + +
+
{ expect(outputPane.queryByText('Input pane hint')).not.toBeInTheDocument(); }); + it('should render every hint inside the hints container, which caps their height', () => { + const { getByTestId, getAllByTestId } = render({ + displayMode: 'table', + nodeTypeHints: [ + { message: 'First hint', location: 'outputPane' }, + { message: 'Second hint', location: 'outputPane' }, + ], + paneType: 'output', + }); + + const hintsContainer = getByTestId('run-data-hints'); + + expect(within(hintsContainer).getAllByTestId('node-hint')).toHaveLength(2); + expect(getAllByTestId('node-hint')).toHaveLength(2); + expect(hintsContainer).toHaveClass('hints'); + }); + it('should hide an afterExecution hint before the node has run', () => { const { queryByText } = render({ displayMode: 'table', diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue index 814953ff8e9..1e1aab7efc8 100644 --- a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunData.vue @@ -2,6 +2,7 @@ import { useStorage } from '@n8n/composables/useStorage'; import { saveAs } from 'file-saver'; import NodeSettingsHint from '@/features/ndv/settings/components/NodeSettingsHint.vue'; +import RunDataHints from '@/features/ndv/runData/components/RunDataHints.vue'; import type { IBinaryData, IConnectedNode, @@ -1713,7 +1714,9 @@ defineExpose({ enterEditMode });
+ +
@@ -1725,16 +1728,10 @@ defineExpose({ enterEditMode }); v-if="!props.disableSettingsHint && props.paneType === 'output'" :node="node" /> - - - + +
+
({ + message: `The field '${field}' wasn't found in any input item`, + location: 'outputPane', + group: { ...FIELD_NOT_FOUND_GROUP, label: field }, +}); + +const renderComponent = createComponentRenderer(RunDataHints); + +describe('RunDataHints', () => { + it('should render one callout per ungrouped hint', () => { + const { getAllByTestId, getByText, queryByTestId } = renderComponent({ + props: { + hints: [{ message: 'First hint' }, { message: 'Second hint' }] satisfies NodeHint[], + }, + }); + + expect(getAllByTestId('node-hint')).toHaveLength(2); + expect(getByText('First hint')).toBeInTheDocument(); + expect(getByText('Second hint')).toBeInTheDocument(); + expect(queryByTestId('node-hint-toggle')).not.toBeInTheDocument(); + }); + + it('should render exact duplicate hints only once', () => { + const { getAllByTestId, getByTestId, getByText } = renderComponent({ + props: { + hints: Array.from({ length: 40 }, () => ({ + message: 'Unable to optimize bulk insert due to expression in Data table ID', + location: 'outputPane', + })) satisfies NodeHint[], + }, + }); + + expect(getAllByTestId('node-hint')).toHaveLength(1); + expect( + getByText('Unable to optimize bulk insert due to expression in Data table ID'), + ).toBeInTheDocument(); + expect(getByTestId('node-hint-repeated-count')).toHaveTextContent('Occurred 40 times'); + }); + + it('should keep hints with the same message but different themes separate', () => { + const { getAllByTestId } = renderComponent({ + props: { + hints: [ + { message: 'Shared message', type: 'info' }, + { message: 'Shared message', type: 'warning' }, + ] satisfies NodeHint[], + }, + }); + + expect(getAllByTestId('node-hint')).toHaveLength(2); + }); + + it('should collapse hints sharing a group into a single callout with the count', () => { + const { getAllByTestId, getByTestId, queryByText } = renderComponent({ + props: { + hints: [ + fieldNotFoundHint('customerEmail'), + fieldNotFoundHint('billingCity'), + fieldNotFoundHint('orderTotal'), + ], + }, + }); + + expect(getAllByTestId('node-hint')).toHaveLength(1); + expect(getByTestId('node-hint-summary')).toHaveTextContent( + "3 fields weren't found in your input items", + ); + expect( + queryByText("The field 'customerEmail' wasn't found in any input item"), + ).not.toBeInTheDocument(); + }); + + it('should use the most severe theme for grouped hints', () => { + const { getByTestId } = renderComponent({ + props: { + hints: [ + { + message: 'Minor issue', + type: 'info', + group: { key: 'mixedSeverity', summary: '{count} issues' }, + }, + { + message: 'Critical issue', + type: 'danger', + group: { key: 'mixedSeverity', summary: '{count} issues' }, + }, + ] satisfies NodeHint[], + }, + }); + + expect(getByTestId('node-hint')).toHaveClass('danger'); + }); + + it('should replace every count placeholder in a grouped summary', () => { + const { getByTestId } = renderComponent({ + props: { + hints: [ + { + message: 'First issue', + group: { key: 'shared', summary: '{count} of {count} issues' }, + }, + { + message: 'Second issue', + group: { key: 'shared', summary: '{count} of {count} issues' }, + }, + ] satisfies NodeHint[], + }, + }); + + expect(getByTestId('node-hint-summary')).toHaveTextContent('2 of 2 issues'); + }); + + it('should list just the labels when an expanded group provides them', async () => { + const { getByTestId, getAllByTestId, queryAllByTestId } = renderComponent({ + props: { + hints: [fieldNotFoundHint('customerEmail'), fieldNotFoundHint('billingCity')], + }, + }); + + expect(queryAllByTestId('node-hint-message')).toHaveLength(0); + + await userEvent.click(getByTestId('node-hint-toggle')); + + const messages = getAllByTestId('node-hint-message'); + expect(messages).toHaveLength(2); + expect(messages[0]).toHaveTextContent('customerEmail'); + expect(messages[1]).toHaveTextContent('billingCity'); + // The summary already carries the sentence, so it isn't repeated per field + expect(getByTestId('node-hint-details')).not.toHaveTextContent("wasn't found"); + expect(getByTestId('node-hint-toggle')).toHaveAttribute('aria-expanded', 'true'); + + await userEvent.click(getByTestId('node-hint-toggle')); + + expect(queryAllByTestId('node-hint-message')).toHaveLength(0); + }); + + it('should expand and collapse grouped hints from the keyboard', async () => { + const { getByRole, getAllByTestId, queryAllByTestId } = renderComponent({ + props: { + hints: [fieldNotFoundHint('customerEmail'), fieldNotFoundHint('billingCity')], + }, + }); + + const toggle = getByRole('button', { + name: "2 fields weren't found in your input items", + }); + + toggle.focus(); + await userEvent.keyboard('{Enter}'); + + expect(getAllByTestId('node-hint-message')).toHaveLength(2); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(toggle).toHaveFocus(); + + await userEvent.keyboard('{Enter}'); + + expect(queryAllByTestId('node-hint-message')).toHaveLength(0); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveFocus(); + }); + + it('should fall back to the full messages when a group has no labels', async () => { + const { getByTestId, getAllByTestId } = renderComponent({ + props: { + hints: [ + { message: 'First problem', group: { key: 'shared', summary: '{count} problems' } }, + { message: 'Second problem', group: { key: 'shared', summary: '{count} problems' } }, + ] satisfies NodeHint[], + }, + }); + + await userEvent.click(getByTestId('node-hint-toggle')); + + const messages = getAllByTestId('node-hint-message'); + expect(messages[0]).toHaveTextContent('First problem'); + expect(messages[1]).toHaveTextContent('Second problem'); + }); + + it('should render a single grouped hint as a plain callout, without a toggle', () => { + const { getByText, queryByTestId } = renderComponent({ + props: { hints: [fieldNotFoundHint('customerEmail')] }, + }); + + expect( + getByText("The field 'customerEmail' wasn't found in any input item"), + ).toBeInTheDocument(); + expect(queryByTestId('node-hint-toggle')).not.toBeInTheDocument(); + }); + + it('should render every hint separately when a group has an empty summary', () => { + const { getAllByTestId, getByText, queryByTestId } = renderComponent({ + props: { + hints: [ + { message: 'Problem A', group: { key: 'shared', summary: '' } }, + { message: 'Problem B', group: { key: 'shared', summary: '' } }, + ] satisfies NodeHint[], + }, + }); + + expect(getAllByTestId('node-hint')).toHaveLength(2); + expect(getByText('Problem A')).toBeInTheDocument(); + expect(getByText('Problem B')).toBeInTheDocument(); + expect(queryByTestId('node-hint-toggle')).not.toBeInTheDocument(); + }); + + it('should keep ungrouped hints separate from grouped ones and preserve order', () => { + const { getAllByTestId } = renderComponent({ + props: { + hints: [ + { message: 'Standalone warning' }, + fieldNotFoundHint('customerEmail'), + fieldNotFoundHint('billingCity'), + { message: 'Another standalone warning' }, + ], + }, + }); + + const callouts = getAllByTestId('node-hint'); + expect(callouts).toHaveLength(3); + expect(callouts[0]).toHaveTextContent('Standalone warning'); + expect(callouts[1]).toHaveTextContent("2 fields weren't found in your input items"); + expect(callouts[2]).toHaveTextContent('Another standalone warning'); + }); + + it('should collapse hints of different groups independently', async () => { + const { getAllByTestId, getAllByText } = renderComponent({ + props: { + hints: [ + fieldNotFoundHint('customerEmail'), + fieldNotFoundHint('billingCity'), + { + message: "The branch starting with 'Edit Fields' must be connected back", + group: { + key: 'loopBranchNotConnectedBack', + summary: "{count} branches aren't connected back", + }, + }, + { + message: "The branch starting with 'Code' must be connected back", + group: { + key: 'loopBranchNotConnectedBack', + summary: "{count} branches aren't connected back", + }, + }, + ] satisfies NodeHint[], + }, + }); + + const toggles = getAllByTestId('node-hint-toggle'); + expect(toggles).toHaveLength(2); + + await userEvent.click(toggles[1]); + + expect(getAllByTestId('node-hint-message')).toHaveLength(2); + expect(getAllByText(/must be connected back/)).toHaveLength(2); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataHints.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataHints.vue new file mode 100644 index 00000000000..9ace97d8f3f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataHints.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/packages/nodes-base/nodes/Transform/Aggregate/Aggregate.node.ts b/packages/nodes-base/nodes/Transform/Aggregate/Aggregate.node.ts index 1e23d58cdb2..b55dece037a 100644 --- a/packages/nodes-base/nodes/Transform/Aggregate/Aggregate.node.ts +++ b/packages/nodes-base/nodes/Transform/Aggregate/Aggregate.node.ts @@ -14,7 +14,7 @@ import { } from 'n8n-workflow'; import { addBinariesToItem } from './utils'; -import { prepareFieldsArray } from '../utils/utils'; +import { fieldNotFoundHint, prepareFieldsArray } from '../utils/utils'; export class Aggregate implements INodeType { description: INodeTypeDescription = { @@ -439,10 +439,7 @@ export class Aggregate implements INodeType { for (const [field, values] of Object.entries(notFoundedFields)) { if (values.every((value) => !value)) { - hints.push({ - message: `The field '${field}' wasn't found in any input item`, - location: 'outputPane', - }); + hints.push(fieldNotFoundHint(field)); } } diff --git a/packages/nodes-base/nodes/Transform/SplitOut/test/utils.test.ts b/packages/nodes-base/nodes/Transform/SplitOut/test/utils.test.ts index afa8c6da858..4b5448b1768 100644 --- a/packages/nodes-base/nodes/Transform/SplitOut/test/utils.test.ts +++ b/packages/nodes-base/nodes/Transform/SplitOut/test/utils.test.ts @@ -54,6 +54,11 @@ describe('FieldsTracker', () => { { message: "The field 'missingField' wasn't found in any input item", location: 'outputPane', + group: { + key: 'fieldNotFound', + summary: "{count} fields weren't found in your input items", + label: 'missingField', + }, }, ]); }); diff --git a/packages/nodes-base/nodes/Transform/SplitOut/utils.ts b/packages/nodes-base/nodes/Transform/SplitOut/utils.ts index 2d93740d1f6..b26cbde054b 100644 --- a/packages/nodes-base/nodes/Transform/SplitOut/utils.ts +++ b/packages/nodes-base/nodes/Transform/SplitOut/utils.ts @@ -1,5 +1,7 @@ import type { NodeExecutionHint } from 'n8n-workflow'; +import { fieldNotFoundHint } from '../utils/utils'; + export class FieldsTracker { fields: { [key: string]: boolean } = {}; @@ -20,10 +22,7 @@ export class FieldsTracker { for (const [field, value] of Object.entries(this.fields)) { if (!value) { - hints.push({ - message: `The field '${field}' wasn't found in any input item`, - location: 'outputPane', - }); + hints.push(fieldNotFoundHint(field)); } } diff --git a/packages/nodes-base/nodes/Transform/utils/utils.ts b/packages/nodes-base/nodes/Transform/utils/utils.ts index 4f5253b4de2..4998412ceb6 100644 --- a/packages/nodes-base/nodes/Transform/utils/utils.ts +++ b/packages/nodes-base/nodes/Transform/utils/utils.ts @@ -1,5 +1,20 @@ +import type { NodeExecutionHint } from 'n8n-workflow'; import { UserError } from 'n8n-workflow'; +/** + * Nodes that take a list of field names report one hint per field they couldn't + * find. Use the same group key so the UI can collapse them under one summary. + */ +export const fieldNotFoundHint = (field: string): NodeExecutionHint => ({ + message: `The field '${field}' wasn't found in any input item`, + location: 'outputPane', + group: { + key: 'fieldNotFound', + summary: "{count} fields weren't found in your input items", + label: field, + }, +}); + export const prepareFieldsArray = (fields: string | string[], fieldName = 'Fields') => { if (typeof fields === 'string') { return fields diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index ec39581bd84..aee219ef851 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -3022,12 +3022,29 @@ export type TriggerPanelDefinition = { activationHint?: string | { active: string; inactive: string }; }; +/** + * Collapses hints that report the same kind of problem, so a node reporting it + * for 20 fields shows one summary line instead of 20 near-identical callouts. + */ +export type NodeHintGroup = { + /** Hints sharing this key are collapsed together */ + key: string; + /** Text shown while collapsed. `{count}` is replaced with the number of hints in the group. */ + summary: string; + /** + * Short form listed when the group is expanded, e.g. just the field name. + * Falls back to `message` when not set. + */ + label?: string; +}; + export type NodeHint = { message: string; type?: 'info' | 'warning' | 'danger'; location?: 'outputPane' | 'inputPane' | 'ndv'; displayCondition?: string; whenToDisplay?: 'always' | 'beforeExecution' | 'afterExecution'; + group?: NodeHintGroup; }; export type NodeExecutionHint = Omit;