fix(editor): Group output panel warnings to keep it usable (#36619)

This commit is contained in:
Daria
2026-08-21 12:36:31 +00:00
committed by GitHub
parent a9cfe9835d
commit 37aee61ff4
11 changed files with 617 additions and 22 deletions
@@ -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",
@@ -276,10 +276,19 @@ exports[`InputPanel > should render 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<div
class="hints"
data-test-id="run-data-hints"
data-v-5b5900d0=""
>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<div
data-v-5b5900d0=""
>
<!--v-if-->
</div>
<div
@@ -18,7 +18,7 @@ import type { NodePanelType } from '@/features/ndv/shared/ndv.types';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { waitFor, within } from '@testing-library/vue';
import {
createRunExecutionData,
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
@@ -1565,6 +1565,23 @@ describe('RunData', () => {
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',
@@ -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 });
</div>
<slot v-if="!displaysMultipleNodes" name="before-data" />
</div>
<div v-show="!binaryDataDisplayVisible" :class="$style.hints" data-test-id="run-data-hints">
<div v-if="props.calloutMessage || $slots['callout-message']" :class="$style.hintCallout">
<N8nCallout theme="info" data-test-id="run-data-callout">
<slot name="callout-message">
@@ -1725,16 +1728,10 @@ defineExpose({ enterEditMode });
v-if="!props.disableSettingsHint && props.paneType === 'output'"
:node="node"
/>
<N8nCallout
v-for="hint in nodeHints"
:key="hint.message"
:class="$style.hintCallout"
:theme="hint.type || 'info'"
data-test-id="node-hint"
>
<N8nText v-n8n-html="hint.message" size="small"></N8nText>
</N8nCallout>
<RunDataHints v-if="nodeHints.length > 0" :hints="nodeHints" :class="$style.nodeHints" />
</div>
<div v-show="!binaryDataDisplayVisible">
<div
v-if="showBranchSwitch && !isExecutionRedacted"
:class="$style.outputs"
@@ -2340,7 +2337,9 @@ defineExpose({ enterEditMode });
.dataContainer {
position: relative;
overflow-y: auto;
height: 100%;
/* Keep the data area within the space left by header, hints, and pagination. */
flex: 1 1 auto;
min-height: 0;
}
.dataDisplay {
@@ -2524,6 +2523,14 @@ defineExpose({ enterEditMode });
border-radius: 0;
}
.hints {
/* Rare fallback: keep long hint lists from pushing output data out of the pane. */
max-height: 40%;
overflow-y: auto;
flex-shrink: 0;
scrollbar-width: thin;
}
.hintCallout {
margin-bottom: var(--spacing--xs);
margin-left: var(--ndv--spacing);
@@ -2534,6 +2541,14 @@ defineExpose({ enterEditMode });
}
}
.nodeHints {
margin: 0 var(--ndv--spacing) var(--spacing--xs) var(--ndv--spacing);
.compact & {
margin: 0 var(--spacing--2xs) var(--spacing--2xs) var(--spacing--2xs);
}
}
.schema {
padding: 0 var(--ndv--spacing);
}
@@ -0,0 +1,268 @@
import userEvent from '@testing-library/user-event';
import type { NodeHint } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import RunDataHints from './RunDataHints.vue';
const FIELD_NOT_FOUND_GROUP = {
key: 'fieldNotFound',
summary: "{count} fields weren't found in your input items",
};
const fieldNotFoundHint = (field: string): NodeHint => ({
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);
});
});
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { NodeHint } from 'n8n-workflow';
import { N8nCallout, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
type HintTheme = NonNullable<NodeHint['type']>;
type HintEntry = {
key: string;
theme: HintTheme;
hints: NodeHint[];
repeatedCount?: number;
/** Only set for grouped hints; `{count}` is interpolated on render */
summary?: string;
};
const HINT_THEME_SEVERITY: Record<HintTheme, number> = {
info: 0,
warning: 1,
danger: 2,
};
const props = defineProps<{
hints: NodeHint[];
}>();
const i18n = useI18n();
const expandedKeys = ref(new Set<string>());
/**
* Exact duplicates collapse at display time; grouped hints still need a visible summary.
*/
const entries = computed<HintEntry[]>(() => {
const groups = new Map<string, HintEntry>();
const hintEntries = new Map<string, HintEntry>();
return props.hints.reduce<HintEntry[]>((acc, hint) => {
const key = hintKey(hint);
const existingDuplicate = hintEntries.get(key);
if (existingDuplicate) {
if (!existingDuplicate.summary) {
existingDuplicate.repeatedCount = (existingDuplicate.repeatedCount ?? 1) + 1;
}
return acc;
}
const theme = hint.type ?? 'info';
if (!hint.group || hint.group.summary.trim() === '') {
const entry = { key: `hint:${key}`, theme, hints: [hint] };
hintEntries.set(key, entry);
acc.push(entry);
return acc;
}
const existing = groups.get(hint.group.key);
if (existing) {
existing.theme = mostSevereTheme(existing.theme, theme);
existing.hints.push(hint);
hintEntries.set(key, existing);
return acc;
}
const entry: HintEntry = {
key: `group:${hint.group.key}`,
theme,
hints: [hint],
summary: hint.group.summary,
};
groups.set(hint.group.key, entry);
hintEntries.set(key, entry);
acc.push(entry);
return acc;
}, []);
});
function hintKey(hint: NodeHint) {
const group = hint.group
? {
key: hint.group.key,
summary: hint.group.summary,
label: hint.group.label ?? null,
}
: null;
return JSON.stringify({
message: hint.message,
type: hint.type ?? null,
location: hint.location ?? null,
whenToDisplay: hint.whenToDisplay ?? null,
displayCondition: hint.displayCondition ?? null,
group,
});
}
function isCollapsible(entry: HintEntry) {
return entry.hints.length > 1 && !!entry.summary;
}
function mostSevereTheme(current: HintTheme, next: HintTheme) {
return HINT_THEME_SEVERITY[next] > HINT_THEME_SEVERITY[current] ? next : current;
}
function summaryText(entry: HintEntry) {
return entry.summary?.replaceAll('{count}', entry.hints.length.toString()) ?? '';
}
function repeatedText(entry: HintEntry) {
return i18n.baseText('ndv.nodeHints.repeatedCount', {
interpolate: { count: entry.repeatedCount ?? 0 },
});
}
function hasLabels(entry: HintEntry) {
return entry.hints.every((hint) => !!hint.group?.label);
}
function isExpanded(entry: HintEntry) {
return expandedKeys.value.has(entry.key);
}
function toggle(entry: HintEntry) {
const next = new Set(expandedKeys.value);
if (next.has(entry.key)) {
next.delete(entry.key);
} else {
next.add(entry.key);
}
expandedKeys.value = next;
}
</script>
<template>
<div :class="$style.hints">
<N8nCallout
v-for="entry in entries"
:key="entry.key"
:theme="entry.theme"
:class="isCollapsible(entry) && isExpanded(entry) ? $style.expandedHint : undefined"
data-test-id="node-hint"
>
<template v-if="isCollapsible(entry)">
<button
type="button"
:class="$style.summaryToggle"
:aria-expanded="isExpanded(entry)"
data-test-id="node-hint-toggle"
@click="toggle(entry)"
>
<N8nText size="small" tag="span" data-test-id="node-hint-summary">
{{ summaryText(entry) }}
</N8nText>
<N8nIcon :icon="isExpanded(entry) ? 'chevron-up' : 'chevron-down'" size="small" />
</button>
<ul
v-if="isExpanded(entry)"
:class="hasLabels(entry) ? $style.labels : $style.messages"
data-test-id="node-hint-details"
>
<li v-for="hint in entry.hints" :key="hintKey(hint)" data-test-id="node-hint-message">
<N8nText v-if="hint.group?.label" size="small">{{ hint.group.label }}</N8nText>
<N8nText v-else v-n8n-html="hint.message" size="small" />
</li>
</ul>
</template>
<template v-else>
<N8nText v-n8n-html="entry.hints[0].message" size="small" />
<N8nText
v-if="entry.repeatedCount && entry.repeatedCount > 1"
:class="$style.repeatedCount"
size="small"
color="text-light"
data-test-id="node-hint-repeated-count"
>
{{ repeatedText(entry) }}
</N8nText>
</template>
</N8nCallout>
</div>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/_focus.scss' as focus;
.hints {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.expandedHint {
align-items: flex-start;
> :first-child {
align-items: flex-start;
}
}
.summaryToggle {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
margin: 0;
padding: 0;
border: none;
border-radius: var(--radius--3xs);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:focus-visible {
@include focus.focus-ring;
}
}
.messages {
margin: var(--spacing--2xs) 0 0;
padding-left: var(--spacing--sm);
list-style: disc;
> li:not(:last-child) {
margin-bottom: var(--spacing--3xs);
}
}
.labels {
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing--3xs);
margin: var(--spacing--2xs) 0 0;
padding: 0;
list-style: none;
> li:not(:last-child)::after {
content: ',';
}
}
.repeatedCount {
margin-left: var(--spacing--3xs);
}
</style>
@@ -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));
}
}
@@ -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',
},
},
]);
});
@@ -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));
}
}
@@ -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
+17
View File
@@ -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<NodeHint, 'whenToDisplay' | 'displayCondition'>;