mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(editor): Group output panel warnings to keep it usable (#36619)
This commit is contained in:
@@ -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",
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
Reference in New Issue
Block a user