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 });
({
+ 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 @@
+
+
+
+
+
+
+
+
+
+ {{ hint.group.label }}
+
+
+
+
+
+
+
+ {{ repeatedText(entry) }}
+
+
+
+
+
+
+
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;