mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-05 02:59:27 +02:00
feat(editor): Evaluations empty-state landing page (no-changelog) (#31319)
This commit is contained in:
parent
bca1e08ea8
commit
a7f660c8d4
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "Couldn’t 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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user