feat(editor): Evaluations empty-state landing page (no-changelog) (#31319)

This commit is contained in:
Benjamin Schroth 2026-06-04 15:56:07 +02:00 committed by GitHub
parent bca1e08ea8
commit a7f660c8d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 282 additions and 3 deletions

View File

@ -56,6 +56,7 @@ import IconLucideAtSign from '~icons/lucide/at-sign';
import IconLucideBadgeCheck from '~icons/lucide/badge-check';
import IconLucideBan from '~icons/lucide/ban';
import IconLucideBell from '~icons/lucide/bell';
import IconLucideBlocks from '~icons/lucide/blocks';
import IconLucideBold from '~icons/lucide/bold';
import IconLucideBook from '~icons/lucide/book';
import IconLucideBookOpen from '~icons/lucide/book-open';
@ -132,6 +133,7 @@ import IconLucideFolder from '~icons/lucide/folder';
import IconLucideFolderOpen from '~icons/lucide/folder-open';
import IconLucideFolderPlus from '~icons/lucide/folder-plus';
import IconLucideFunnel from '~icons/lucide/funnel';
import IconLucideGauge from '~icons/lucide/gauge';
import IconLucideGem from '~icons/lucide/gem';
import IconLucideGift from '~icons/lucide/gift';
import IconLucideGitBranch from '~icons/lucide/git-branch';
@ -549,6 +551,7 @@ export const updatedIconSet = {
ban: IconLucideBan,
'badge-check': IconLucideBadgeCheck,
bell: IconLucideBell,
blocks: IconLucideBlocks,
bold: IconLucideBold,
book: IconLucideBook,
'book-open': IconLucideBookOpen,
@ -622,6 +625,7 @@ export const updatedIconSet = {
'folder-open': IconLucideFolderOpen,
'folder-plus': IconLucideFolderPlus,
funnel: IconLucideFunnel,
gauge: IconLucideGauge,
gem: IconLucideGem,
gift: IconLucideGift,
'git-branch': IconLucideGitBranch,

View File

@ -3741,6 +3741,7 @@
"nodeIssues.credentials.doNotExist.hint": "You can create credentials with the exact name and then they get auto-selected on refresh..",
"nodeIssues.credentials.notIdentified": "Credentials with name {name} exist for {type}.",
"nodeIssues.credentials.notIdentified.hint": "Credentials are not clearly identified. Please select the correct credentials.",
"nodeIssues.credentials.privateNotConnected": "'{name}' private credential is not connected for you. Connect yours to execute this step manually.",
"nodeIssues.credentials.privateRequiresManualTrigger": "Private credentials only work in manually triggered workflows. Change the trigger to a Manual trigger, or switch this credential to Static.",
"nodeIssues.input.missing": "No node connected to required input \"{inputName}\"",
"ndv.trigger.moreInfo": "More info",
@ -5439,6 +5440,15 @@
"evaluations.wizardSidepanel.step3.outputPlaceholder": "Run the evaluation to view the captured output.",
"evaluations.wizardSidepanel.step3.noRun": "Run the evaluation to see results.",
"evaluations.wizardSidepanel.hydrate.error": "Couldnt load your previous setup. Starting with a blank wizard.",
"evaluations.emptyState.title": "Evaluations",
"evaluations.emptyState.description": "Evals are automated tests that evaluate agent workflow outputs using model-graded checks.",
"evaluations.emptyState.catchIssues.title": "Catch issues early",
"evaluations.emptyState.catchIssues.description": "Spot failures and unexpected behaviour before going live.",
"evaluations.emptyState.buildConfidence.title": "Build with confidence",
"evaluations.emptyState.buildConfidence.description": "Make changes and iterate without relying on guesswork.",
"evaluations.emptyState.measurePerformance.title": "Measure performance",
"evaluations.emptyState.measurePerformance.description": "Track performance and areas for improvement in workflows.",
"evaluations.emptyState.getStarted": "Get started",
"evaluations.wizardSidepanel.customCheck.title": "Custom check",
"evaluations.wizardSidepanel.customCheck.name": "Name",
"evaluations.wizardSidepanel.customCheck.name.placeholder": "My custom check",
@ -5827,6 +5837,7 @@
"instanceAi.settings.sandboxTimeout.label": "Sandbox timeout (ms)",
"instanceAi.settings.searchCredential.label": "Search credential",
"instanceAi.settings.subAgentMaxSteps.label": "Sub-agent max steps",
"instanceAi.settings.browserMcp.label": "Chrome DevTools MCP",
"instanceAi.settings.mcpServers.label": "MCP servers",
"instanceAi.settings.mcpServers.placeholder": "name=url, comma-separated",
"instanceAi.settings.section.permissions": "Permissions",

View File

@ -467,11 +467,21 @@ XMLHttpRequest.prototype.send = function (this: XMLHttpRequest) {
// broad filter would mask that signal. Sibling to the rAF polyfill (DEVP-201,
// DEVP-206) and the XHR short-circuit above — both narrow harness defences
// against Vitest 4's post-teardown rejection promotion.
//
// Match BOTH module and non-module SCSS style blocks. `@vitejs/plugin-vue`
// emits `<style lang="scss">` as `...?vue&type=style&index=N&lang.scss` and
// `<style module lang="scss">` as `...&lang.module.scss` (the CSS-modules
// codegen rewrites the request via `.replace(/\.(\w+)$/, '.module.$1')`). A
// component can ship both kinds (e.g. design-system's `Button.vue`), so the
// `.module.` segment must stay optional or the non-module block's teardown
// rejection slips through and gets re-thrown. The `?vue&type=style` anchor
// keeps this scoped to Vue SFC style virtual modules, so DEVP-206 timer
// errors (not style URLs) are still surfaced.
process.on('unhandledRejection', (reason) => {
if (
reason instanceof Error &&
reason.name === 'EnvironmentTeardownError' &&
/\?vue&type=style.*lang\.module\.scss/.test(reason.message)
/\?vue&type=style.*lang(\.module)?\.scss/.test(reason.message)
) {
return;
}

View File

@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import EvaluationsEmptyState from './EvaluationsEmptyState.vue';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({ baseText: (key: string) => `mocked-${key}` }),
}));
const renderComponent = createComponentRenderer(EvaluationsEmptyState);
describe('EvaluationsEmptyState', () => {
it('renders the heading, description, and all three feature columns', () => {
const { getByTestId, getByText } = renderComponent();
expect(getByTestId('evaluations-empty-state')).toBeInTheDocument();
// Title + description copy comes from i18n keys; using the mocked
// prefix ensures we verify the keys we intended (not arbitrary text).
expect(getByText('mocked-evaluations.emptyState.title')).toBeInTheDocument();
expect(getByText('mocked-evaluations.emptyState.description')).toBeInTheDocument();
expect(getByText('mocked-evaluations.emptyState.catchIssues.title')).toBeInTheDocument();
expect(getByText('mocked-evaluations.emptyState.buildConfidence.title')).toBeInTheDocument();
expect(getByText('mocked-evaluations.emptyState.measurePerformance.title')).toBeInTheDocument();
});
it('emits get-started when the CTA is clicked', async () => {
const { getByTestId, emitted } = renderComponent();
await userEvent.click(getByTestId('evaluations-empty-state-get-started'));
expect(emitted('getStarted')).toBeTruthy();
});
it('disables the CTA when disabled prop is set (read-only environments)', () => {
const { getByTestId } = renderComponent({ props: { disabled: true } });
expect(getByTestId('evaluations-empty-state-get-started')).toBeDisabled();
});
});

View File

@ -0,0 +1,193 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
defineProps<{
disabled?: boolean;
}>();
const emit = defineEmits<{
getStarted: [];
}>();
const locale = useI18n();
type Feature = {
icon: IconName;
titleKey:
| 'evaluations.emptyState.catchIssues.title'
| 'evaluations.emptyState.buildConfidence.title'
| 'evaluations.emptyState.measurePerformance.title';
descriptionKey:
| 'evaluations.emptyState.catchIssues.description'
| 'evaluations.emptyState.buildConfidence.description'
| 'evaluations.emptyState.measurePerformance.description';
};
const features: Feature[] = [
{
icon: 'bug',
titleKey: 'evaluations.emptyState.catchIssues.title',
descriptionKey: 'evaluations.emptyState.catchIssues.description',
},
{
// `blocks` visually matches the design's "puzzle pieces" feel for the
// iterate-with-confidence message better than the previously chosen
// `grid-2x2` (a flat grid).
icon: 'blocks',
titleKey: 'evaluations.emptyState.buildConfidence.title',
descriptionKey: 'evaluations.emptyState.buildConfidence.description',
},
{
// `gauge` (dial) matches the "measure" metaphor replaces the
// trend-line icon that didn't read as a measurement device.
icon: 'gauge',
titleKey: 'evaluations.emptyState.measurePerformance.title',
descriptionKey: 'evaluations.emptyState.measurePerformance.description',
},
];
</script>
<template>
<div :class="$style.wrapper" data-test-id="evaluations-empty-state">
<header :class="$style.header">
<N8nText tag="h2" size="xlarge" color="text-dark" bold :class="$style.title">
{{ locale.baseText('evaluations.emptyState.title') }}
</N8nText>
<N8nText size="medium" color="text-base" :class="$style.description">
{{ locale.baseText('evaluations.emptyState.description') }}
</N8nText>
</header>
<div :class="$style.featuresCard">
<div v-for="feature in features" :key="feature.titleKey" :class="$style.feature">
<N8nIcon :icon="feature.icon" :size="24" :class="$style.featureIcon" />
<N8nText size="medium" bold color="text-dark" :class="$style.featureTitle">
{{ locale.baseText(feature.titleKey) }}
</N8nText>
<N8nText size="medium" color="text-base">
{{ locale.baseText(feature.descriptionKey) }}
</N8nText>
</div>
</div>
<div :class="$style.footer">
<N8nButton
variant="solid"
size="medium"
type="button"
:disabled="disabled"
data-test-id="evaluations-empty-state-get-started"
@click="emit('getStarted')"
>
{{ locale.baseText('evaluations.emptyState.getStarted') }}
</N8nButton>
</div>
</div>
</template>
<style module lang="scss">
// New layout (per design):
//
// Title (centered, no border)
// Description (centered, no border)
//
//
// feat 1 feat 2 feat 3 (bordered card)
//
// [ Get started ]
//
//
// The features card is the only bordered block; title/description sit on
// the page background, the CTA sits below the card, both centered.
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
// Vertically center the empty-state stack inside the parent (.evaluationsView
// is already `height: 100%; display: flex`). `justify-content: center` here
// pulls the title/card/CTA group to the vertical midpoint; the inner stack
// keeps its tight vertical rhythm via `gap`.
justify-content: center;
gap: var(--spacing--md);
width: 100%;
max-width: 720px;
height: 100%;
margin: 0 auto;
padding: var(--spacing--lg);
}
.header {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--3xs);
text-align: center;
}
.title {
margin: 0;
font-size: var(--font-size--md, 18px);
}
.description {
display: block;
max-width: 480px;
}
.featuresCard {
display: grid;
grid-template-columns: repeat(3, 1fr);
// `gap: 0` so the vertical dividers added via `border-right` on .feature
// sit flush against neighbours instead of having grid-gap whitespace either
// side. The internal padding on each .feature keeps the columns breathing.
gap: 0;
width: 100%;
padding: var(--spacing--md);
background-color: var(--background--surface);
border: var(--border);
border-radius: var(--radius--sm);
}
.feature {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--md);
border-right: var(--border);
// First column doesn't need left padding its left edge aligns with the
// card's own inner padding.
&:first-child {
padding-left: 0;
}
// Last column drops the divider AND right padding for symmetry with the
// first column's flush left edge.
&:last-child {
padding-right: 0;
border-right: none;
}
}
.featureIcon {
color: var(--color--text);
margin-bottom: var(--spacing--2xs);
}
.featureTitle {
display: block;
}
.footer {
display: flex;
justify-content: center;
}
@media (max-width: 640px) {
.featuresCard {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useUsageStore } from '@/features/settings/usage/usage.store';
import { useAsyncState } from '@vueuse/core';
import { EVALUATIONS_DOCS_URL } from '@/app/constants';
import { EVALUATIONS_DOCS_URL, VIEWS } from '@/app/constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useI18n } from '@n8n/i18n';
@ -10,11 +10,14 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import EvaluationsPaywall from '../components/Paywall/EvaluationsPaywall.vue';
import SetupWizard from '../components/SetupWizard/SetupWizard.vue';
import EvaluationsEmptyState from '../components/EvaluationsEmptyState/EvaluationsEmptyState.vue';
import { N8nCallout, N8nLink, N8nText } from '@n8n/design-system';
import { useWorkflowEvaluationState } from '../composables/useWorkflowEvaluationState';
import { useEvaluationsWizardSidepanelExperiment } from '@/experiments/evaluationsWizardSidepanel/useEvaluationsWizardSidepanelExperiment';
const props = defineProps<{
workflowId: string;
}>();
@ -27,6 +30,9 @@ const telemetry = useTelemetry();
const toast = useToast();
const locale = useI18n();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
const { isFeatureEnabled: isEvaluationsWizardSidepanelEnabled } =
useEvaluationsWizardSidepanelExperiment();
const evaluationsLicensed = computed(() => {
return usageStore.workflowsWithEvaluationsLimit !== 0;
@ -75,6 +81,14 @@ const evaluationsQuotaExceeded = computed(() => {
);
});
function openWizardOnCanvas() {
void router.push({
name: VIEWS.WORKFLOW,
params: { workflowId: props.workflowId },
query: { action: 'openEvaluationsWizard' },
});
}
async function fetchTestRuns() {
if (!workflowIsSaved.value) return;
@ -131,7 +145,16 @@ watch(
<template>
<div :class="$style.evaluationsView">
<template v-if="isReady && showWizard">
<!--
With the wizard-sidepanel experiment on we replace the long-form
setup screen (video + step list) with a focused empty-state card.
The "Get started" CTA navigates to the canvas with the wizard auto-
opened same destination the floating CTA above already targets.
-->
<template v-if="isReady && showWizard && isEvaluationsWizardSidepanelEnabled">
<EvaluationsEmptyState :disabled="isProtectedEnvironment" @get-started="openWizardOnCanvas" />
</template>
<template v-else-if="isReady && showWizard">
<div :class="$style.setupContent">
<div>
<N8nText size="large" color="text-dark" tag="h3" bold>
@ -175,6 +198,7 @@ watch(
height: 100%;
display: flex;
justify-content: center;
position: relative;
}
.setupContent {