feat(editor): Add Production checklist for active workflows (#17756)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Mutasem Aldmour
2025-08-06 11:15:10 +02:00
committed by GitHub
parent b6c7810844
commit 6046d24c74
46 changed files with 3443 additions and 246 deletions

View File

@@ -351,4 +351,23 @@ function hideGithubButton() {
.github-button:hover .close-github-button {
display: block;
}
@media (max-width: 1390px) {
.github-button {
padding: var(--spacing-5xs) var(--spacing-xs);
}
}
@media (max-width: 1340px) {
.github-button {
border-left: 0;
padding-left: 0;
}
}
@media (max-width: 1290px) {
.github-button {
display: none;
}
}
</style>

View File

@@ -21,6 +21,7 @@ import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import WorkflowProductionChecklist from '@/components/WorkflowProductionChecklist.vue';
import { ResourceType } from '@/utils/projects.utils';
import { useProjectsStore } from '@/stores/projects.store';
@@ -63,6 +64,9 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { sanitizeFilename } from '@/utils/fileUtils';
import { I18nT } from 'vue-i18n';
const WORKFLOW_NAME_MAX_WIDTH_SMALL_SCREENS = 150;
const WORKFLOW_NAME_MAX_WIDTH_WIDE_SCREENS = 200;
const props = defineProps<{
readOnly?: boolean;
id: IWorkflowDb['id'];
@@ -693,7 +697,7 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
class="name-container"
data-test-id="canvas-breadcrumbs"
>
<template #default>
<template #default="{ bp }">
<FolderBreadcrumbs
:current-folder="currentFolderForBreadcrumbs"
:current-folder-as-link="true"
@@ -713,6 +717,11 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
class="name"
:model-value="name"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:max-width="
['XS', 'SM'].includes(bp)
? WORKFLOW_NAME_MAX_WIDTH_SMALL_SCREENS
: WORKFLOW_NAME_MAX_WIDTH_WIDE_SCREENS
"
:read-only="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
:disabled="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
@update:model-value="onNameSubmit"
@@ -774,6 +783,7 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
</span>
<PushConnectionTracker class="actions">
<WorkflowProductionChecklist v-if="!isNewWorkflow" :workflow="workflowsStore.workflow" />
<span :class="`activator ${$style.group}`">
<WorkflowActivator
:is-archived="isArchived"
@@ -941,6 +951,26 @@ $--header-spacing: 20px;
}
}
}
@media (max-width: 1390px) {
.name-container {
margin-right: var(--spacing-xs);
}
.actions {
gap: var(--spacing-xs);
}
}
@media (max-width: 1350px) {
.name-container {
margin-right: var(--spacing-2xs);
}
.actions {
gap: var(--spacing-2xs);
}
}
</style>
<style module lang="scss">

View File

@@ -16,22 +16,35 @@ const renderComponent = createComponentRenderer(WorkflowHistoryButton, {
'router-link': {
template: '<div><slot /></div>',
},
N8nTooltip: {
template: '<div><slot /><slot name="content" /></div>',
},
N8nIconButton: true,
N8nLink: {
template: '<a @click="$emit(\'click\')"><slot /></a>',
},
I18nT: {
template: '<span><slot name="link" /></span>',
},
},
},
});
describe('WorkflowHistoryButton', () => {
it('should be disabled if the feature is disabled', async () => {
const { getByRole, emitted } = renderComponent({
const { getByRole, queryByTestId, emitted } = renderComponent({
props: {
workflowId: '1',
isNewWorkflow: false,
isFeatureEnabled: false,
},
});
expect(getByRole('button')).toBeDisabled();
await userEvent.hover(getByRole('button'));
const button = queryByTestId('workflow-history-button');
expect(button).toHaveAttribute('disabled', 'true');
if (!button) {
throw new Error('Button does not exist');
}
await userEvent.hover(button);
expect(getByRole('tooltip')).toBeVisible();
within(getByRole('tooltip')).getByText('View plans').click();
@@ -40,24 +53,25 @@ describe('WorkflowHistoryButton', () => {
});
it('should be disabled if the feature is enabled but the workflow is new', async () => {
const { getByRole } = renderComponent({
const { queryByTestId } = renderComponent({
props: {
workflowId: '1',
isNewWorkflow: true,
isFeatureEnabled: true,
},
});
expect(getByRole('button')).toBeDisabled();
expect(queryByTestId('workflow-history-button')).toHaveAttribute('disabled', 'true');
});
it('should be enabled if the feature is enabled and the workflow is not new', async () => {
const { getByRole } = renderComponent({
const { container, queryByTestId } = renderComponent({
props: {
workflowId: '1',
isNewWorkflow: false,
isFeatureEnabled: true,
},
});
expect(getByRole('button')).toBeEnabled();
expect(queryByTestId('workflow-history-button')).toHaveAttribute('disabled', 'false');
expect(container).toMatchSnapshot();
});
});

View File

@@ -26,14 +26,13 @@ const workflowHistoryRoute = computed<{ name: string; params: { workflowId: stri
<template>
<N8nTooltip placement="bottom">
<RouterLink :to="workflowHistoryRoute" :class="$style.workflowHistoryButton">
<RouterLink :to="workflowHistoryRoute">
<N8nIconButton
:disabled="isNewWorkflow || !isFeatureEnabled"
data-test-id="workflow-history-button"
type="tertiary"
type="highlight"
icon="history"
size="medium"
text
/>
</RouterLink>
<template #content>
@@ -53,22 +52,3 @@ const workflowHistoryRoute = computed<{ name: string; params: { workflowId: stri
</template>
</N8nTooltip>
</template>
<style lang="scss" module>
.workflowHistoryButton {
width: 30px;
height: 30px;
color: var(--color-text-dark);
border-radius: var(--border-radius-base);
&:hover {
background-color: var(--color-background-base);
}
:disabled {
background: transparent;
border: none;
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`WorkflowHistoryButton > should be enabled if the feature is enabled and the workflow is not new 1`] = `
<div>
<div
class="el-tooltip__trigger"
to="[object Object]"
>
<n8n-icon-button-stub
active="false"
data-test-id="workflow-history-button"
disabled="false"
icon="history"
loading="false"
outline="false"
size="medium"
text="false"
type="highlight"
/>
</div>
<!--teleport start-->
<!--teleport end-->
</div>
`;

View File

@@ -270,7 +270,6 @@ watch(
<style lang="scss" module>
.activeStatusText {
width: 64px; // Required to avoid jumping when changing active state
padding-right: var(--spacing-2xs);
box-sizing: border-box;
display: inline-block;

View File

@@ -0,0 +1,846 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { ref } from 'vue';
import WorkflowProductionChecklist from '@/components/WorkflowProductionChecklist.vue';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowSettingsCache } from '@/composables/useWorkflowsCache';
import { useUIStore } from '@/stores/ui.store';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useRouter } from 'vue-router';
import type { IWorkflowDb } from '@/Interface';
import type { SourceControlPreferences } from '@/types/sourceControl.types';
import {
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
VIEWS,
MODAL_CONFIRM,
ERROR_WORKFLOW_DOCS_URL,
TIME_SAVED_DOCS_URL,
EVALUATIONS_DOCS_URL,
} from '@/constants';
import type { INodeTypeDescription } from 'n8n-workflow';
vi.mock('vue-router', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importOriginal<typeof import('vue-router')>();
return {
...actual,
useRouter: vi.fn(),
};
});
vi.mock('@/composables/useWorkflowsCache', () => ({
useWorkflowSettingsCache: vi.fn(),
}));
vi.mock('@/composables/useMessage', () => ({
useMessage: vi.fn(),
}));
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(),
}));
vi.mock('@n8n/i18n', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importOriginal<typeof import('@n8n/i18n')>();
return {
...actual,
useI18n: () => ({
baseText: (key: string) => key,
}),
i18n: {
...actual.i18n,
baseText: (key: string) => key,
},
};
});
const mockWorkflow: IWorkflowDb = {
id: 'test-workflow-id',
name: 'Test Workflow',
active: true,
nodes: [],
settings: {
executionOrder: 'v1',
},
connections: {},
versionId: '1',
createdAt: Date.now(),
updatedAt: Date.now(),
isArchived: false,
};
const mockAINodeType: Partial<INodeTypeDescription> = {
codex: {
categories: ['AI'],
},
};
const mockNonAINodeType: Partial<INodeTypeDescription> = {
codex: {
categories: ['Core Nodes'],
},
};
// eslint-disable-next-line
let mockN8nSuggestedActionsProps: Record<string, any> = {};
// eslint-disable-next-line
let mockN8nSuggestedActionsEmits: Record<string, any> = {};
const mockN8nSuggestedActions = {
name: 'N8nSuggestedActions',
props: ['actions', 'ignoreAllLabel', 'popoverAlignment', 'open', 'title', 'notice'],
emits: ['action-click', 'ignore-click', 'ignore-all', 'update:open'],
// eslint-disable-next-line
setup(props: any, { emit }: any) {
// Store props in the outer variable
mockN8nSuggestedActionsProps = props;
// Store emit functions
mockN8nSuggestedActionsEmits = {
'action-click': (id: string) => emit('action-click', id),
'ignore-click': (id: string) => emit('ignore-click', id),
'ignore-all': () => emit('ignore-all'),
'update:open': (open: boolean) => emit('update:open', open),
};
return { props };
},
template: '<div data-test-id="n8n-suggested-actions-stub" />',
};
const renderComponent = createComponentRenderer(WorkflowProductionChecklist, {
global: {
stubs: {
N8nSuggestedActions: mockN8nSuggestedActions,
},
},
});
describe('WorkflowProductionChecklist', () => {
let router: ReturnType<typeof useRouter>;
let workflowsCache: ReturnType<typeof useWorkflowSettingsCache>;
let message: ReturnType<typeof useMessage>;
let telemetry: ReturnType<typeof useTelemetry>;
let evaluationStore: ReturnType<typeof useEvaluationStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let uiStore: ReturnType<typeof useUIStore>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
beforeEach(() => {
router = {
push: vi.fn(),
} as unknown as ReturnType<typeof useRouter>;
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(router);
workflowsCache = {
isCacheLoading: ref(false),
getMergedWorkflowSettings: vi.fn().mockResolvedValue({
suggestedActions: {},
}),
ignoreSuggestedAction: vi.fn().mockResolvedValue(undefined),
ignoreAllSuggestedActionsForAllWorkflows: vi.fn().mockResolvedValue(undefined),
updateFirstActivatedAt: vi.fn().mockResolvedValue(undefined),
} as unknown as ReturnType<typeof useWorkflowSettingsCache>;
(useWorkflowSettingsCache as ReturnType<typeof vi.fn>).mockReturnValue(workflowsCache);
message = {
confirm: vi.fn().mockResolvedValue(MODAL_CONFIRM),
} as unknown as ReturnType<typeof useMessage>;
(useMessage as ReturnType<typeof vi.fn>).mockReturnValue(message);
telemetry = {
track: vi.fn(),
} as unknown as ReturnType<typeof useTelemetry>;
(useTelemetry as ReturnType<typeof vi.fn>).mockReturnValue(telemetry);
});
afterEach(() => {
vi.clearAllMocks();
mockN8nSuggestedActionsProps = {};
mockN8nSuggestedActionsEmits = {};
});
describe('Action visibility', () => {
it('should not render when workflow is inactive', () => {
const { container } = renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
},
},
pinia: createTestingPinia(),
});
expect(
container.querySelector('[data-test-id="n8n-suggested-actions-stub"]'),
).not.toBeInTheDocument();
});
it('should not render when cache is loading', () => {
workflowsCache.isCacheLoading.value = true;
const { container } = renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
expect(
container.querySelector('[data-test-id="n8n-suggested-actions-stub"]'),
).not.toBeInTheDocument();
});
it('should show evaluations action when AI node exists and evaluations are enabled', async () => {
const pinia = createTestingPinia();
evaluationStore = useEvaluationStore(pinia);
nodeTypesStore = useNodeTypesStore(pinia);
vi.spyOn(evaluationStore, 'isEvaluationEnabled', 'get').mockReturnValue(true);
vi.spyOn(evaluationStore, 'evaluationSetOutputsNodeExist', 'get').mockReturnValue(false);
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [{ type: 'ai-node', typeVersion: 1 }],
},
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: false,
},
{
id: 'evaluations',
title: 'workflowProductionChecklist.evaluations.title',
description: 'workflowProductionChecklist.evaluations.description',
moreInfoLink: EVALUATIONS_DOCS_URL,
completed: false,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: false,
},
]);
});
});
it('should not show evaluations action when no AI node exists', async () => {
const pinia = createTestingPinia();
evaluationStore = useEvaluationStore(pinia);
nodeTypesStore = useNodeTypesStore(pinia);
vi.spyOn(evaluationStore, 'isEvaluationEnabled', 'get').mockReturnValue(true);
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi
.fn()
.mockReturnValue(mockNonAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [{ type: 'regular-node', typeVersion: 1 }],
},
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: false,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: false,
},
]);
});
});
it('should show error workflow action and time saved when not ignored', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: false,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: false,
},
]);
expect(mockN8nSuggestedActionsProps.popoverAlignment).toBe('end');
});
});
it('should hide actions that are ignored', async () => {
workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({
suggestedActions: {
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
},
});
const { container } = renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
// Since all actions are ignored, the component should not render at all
await vi.waitFor(() => {
expect(
container.querySelector('[data-test-id="n8n-suggested-actions-stub"]'),
).not.toBeInTheDocument();
});
});
});
describe('Action interactions', () => {
it('should navigate to evaluations when evaluations action is clicked', async () => {
const pinia = createTestingPinia();
evaluationStore = useEvaluationStore(pinia);
nodeTypesStore = useNodeTypesStore(pinia);
vi.spyOn(evaluationStore, 'isEvaluationEnabled', 'get').mockReturnValue(true);
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [{ type: 'ai-node', typeVersion: 1 }],
},
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate action click
mockN8nSuggestedActionsEmits['action-click']('evaluations');
await vi.waitFor(() => {
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.EVALUATION_EDIT,
params: { name: mockWorkflow.id },
});
});
});
it('should open workflow settings modal when error workflow action is clicked', async () => {
const pinia = createTestingPinia();
uiStore = useUIStore(pinia);
const openModalSpy = vi.spyOn(uiStore, 'openModal');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate action click
mockN8nSuggestedActionsEmits['action-click']('errorWorkflow');
await vi.waitFor(() => {
expect(openModalSpy).toHaveBeenCalledWith(WORKFLOW_SETTINGS_MODAL_KEY);
});
});
it('should open workflow settings modal when time saved action is clicked', async () => {
const pinia = createTestingPinia();
uiStore = useUIStore(pinia);
const openModalSpy = vi.spyOn(uiStore, 'openModal');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate action click
mockN8nSuggestedActionsEmits['action-click']('timeSaved');
await vi.waitFor(() => {
expect(openModalSpy).toHaveBeenCalledWith(WORKFLOW_SETTINGS_MODAL_KEY);
});
});
it('should ignore specific action when ignore is clicked', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate ignore click
mockN8nSuggestedActionsEmits['ignore-click']('errorWorkflow');
await vi.waitFor(() => {
expect(workflowsCache.ignoreSuggestedAction).toHaveBeenCalledWith(
mockWorkflow.id,
'errorWorkflow',
);
expect(telemetry.track).toHaveBeenCalledWith('user clicked ignore suggested action', {
actionId: 'errorWorkflow',
});
});
});
it('should ignore all actions after confirmation', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
expect(mockN8nSuggestedActionsProps.ignoreAllLabel).toBe(
'workflowProductionChecklist.turnOffWorkflowSuggestions',
);
});
// Simulate ignore all click
mockN8nSuggestedActionsEmits['ignore-all']();
await vi.waitFor(() => {
expect(message.confirm).toHaveBeenCalled();
expect(workflowsCache.ignoreAllSuggestedActionsForAllWorkflows).toHaveBeenCalledWith([
'errorWorkflow',
'timeSaved',
]);
expect(telemetry.track).toHaveBeenCalledWith(
'user clicked ignore suggested actions for all workflows',
);
});
});
it('should not ignore all actions if confirmation is cancelled', async () => {
message.confirm = vi.fn().mockResolvedValue('cancel');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate ignore all click
mockN8nSuggestedActionsEmits['ignore-all']();
await vi.waitFor(() => {
expect(message.confirm).toHaveBeenCalled();
expect(workflowsCache.ignoreAllSuggestedActionsForAllWorkflows).not.toHaveBeenCalled();
expect(telemetry.track).not.toHaveBeenCalledWith(
'user clicked ignore suggested actions for all workflows',
);
});
});
});
describe('Popover behavior', () => {
it('should track when popover is opened via update:open event', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Simulate popover open via update:open event
mockN8nSuggestedActionsEmits['update:open'](true);
await vi.waitFor(() => {
expect(telemetry.track).toHaveBeenCalledWith('user opened suggested actions checklist');
});
});
it('should open popover automatically on first workflow activation', async () => {
workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({
suggestedActions: {},
firstActivatedAt: undefined,
});
const { rerender } = renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
},
},
pinia: createTestingPinia(),
});
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});
await vi.waitFor(() => {
expect(workflowsCache.updateFirstActivatedAt).toHaveBeenCalledWith(mockWorkflow.id);
});
// Wait for the setTimeout to execute and popover open state to be set
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.open).toBe(true);
});
});
it('should not open popover automatically if workflow was previously activated', async () => {
workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({
suggestedActions: {},
firstActivatedAt: '2024-01-01',
});
const { rerender } = renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
},
},
pinia: createTestingPinia(),
});
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});
expect(workflowsCache.updateFirstActivatedAt).toHaveBeenCalledWith(mockWorkflow.id);
expect(mockN8nSuggestedActionsProps.open).toBe(false);
});
it('should not open popover when activation modal is active', async () => {
workflowsCache.getMergedWorkflowSettings = vi.fn().mockResolvedValue({
suggestedActions: {},
firstActivatedAt: undefined,
});
const pinia = createTestingPinia();
uiStore = useUIStore(pinia);
// Mock the activation modal as open via the object property
Object.defineProperty(uiStore, 'isModalActiveById', {
value: {
[WORKFLOW_ACTIVE_MODAL_KEY]: true,
},
writable: true,
});
const { rerender } = renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
},
},
pinia,
});
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});
// Should still update first activated at
await vi.waitFor(() => {
expect(workflowsCache.updateFirstActivatedAt).toHaveBeenCalledWith(mockWorkflow.id);
});
// But should not open popover due to modal being active
expect(mockN8nSuggestedActionsProps.open).toBe(false);
});
it('should prevent opening popover when activation modal is active', async () => {
const pinia = createTestingPinia();
uiStore = useUIStore(pinia);
// Mock the activation modal as open via the object property
Object.defineProperty(uiStore, 'isModalActiveById', {
value: {
[WORKFLOW_ACTIVE_MODAL_KEY]: true,
},
writable: true,
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
});
// Try to open popover by simulating user action
mockN8nSuggestedActionsEmits['update:open'](true);
// Should not actually open due to modal being active
expect(mockN8nSuggestedActionsProps.open).toBe(false);
});
});
describe('Completion states', () => {
it('should mark evaluations as completed when evaluation set outputs node exists', async () => {
const pinia = createTestingPinia();
evaluationStore = useEvaluationStore(pinia);
nodeTypesStore = useNodeTypesStore(pinia);
vi.spyOn(evaluationStore, 'isEvaluationEnabled', 'get').mockReturnValue(true);
vi.spyOn(evaluationStore, 'evaluationSetOutputsNodeExist', 'get').mockReturnValue(true);
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [{ type: 'ai-node', typeVersion: 1 }],
},
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: false,
},
{
id: 'evaluations',
title: 'workflowProductionChecklist.evaluations.title',
description: 'workflowProductionChecklist.evaluations.description',
moreInfoLink: EVALUATIONS_DOCS_URL,
completed: true,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: false,
},
]);
});
});
it('should mark error workflow as completed when error workflow is set', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
errorWorkflow: 'error-workflow-id',
},
},
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: true,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: false,
},
]);
});
});
it('should mark time saved as completed when time saved is set', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
timeSavedPerExecution: 10,
},
},
},
pinia: createTestingPinia(),
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
id: 'errorWorkflow',
title: 'workflowProductionChecklist.errorWorkflow.title',
description: 'workflowProductionChecklist.errorWorkflow.description',
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: false,
},
{
id: 'timeSaved',
title: 'workflowProductionChecklist.timeSaved.title',
description: 'workflowProductionChecklist.timeSaved.description',
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: true,
},
]);
});
});
});
describe('Notice functionality', () => {
it('should pass notice prop when source control branch is read-only', async () => {
const pinia = createTestingPinia();
sourceControlStore = useSourceControlStore(pinia);
// Mock branch as read-only
sourceControlStore.preferences = {
branchReadOnly: true,
} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
expect(mockN8nSuggestedActionsProps.notice).toBe(
'workflowProductionChecklist.readOnlyNotice',
);
});
});
it('should not pass notice prop when source control branch is not read-only', async () => {
const pinia = createTestingPinia();
sourceControlStore = useSourceControlStore(pinia);
// Mock branch as not read-only
sourceControlStore.preferences = {
branchReadOnly: false,
} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
expect(mockN8nSuggestedActionsProps.notice).toBe('');
});
});
it('should default to empty notice when source control preferences are undefined', async () => {
const pinia = createTestingPinia();
sourceControlStore = useSourceControlStore(pinia);
// Mock preferences with no branchReadOnly property
sourceControlStore.preferences = {} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
expect(mockN8nSuggestedActionsProps.notice).toBe('');
});
});
});
});

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ActionType, WorkflowSettings } from '@/composables/useWorkflowsCache';
import { useWorkflowSettingsCache } from '@/composables/useWorkflowsCache';
import { useUIStore } from '@/stores/ui.store';
import { N8nSuggestedActions } from '@n8n/design-system';
import type { IWorkflowDb } from '@/Interface';
import {
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
VIEWS,
MODAL_CONFIRM,
EVALUATIONS_DOCS_URL,
ERROR_WORKFLOW_DOCS_URL,
TIME_SAVED_DOCS_URL,
} from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSourceControlStore } from '@/stores/sourceControl.store';
const props = defineProps<{
workflow: IWorkflowDb;
}>();
const i18n = useI18n();
const router = useRouter();
const evaluationStore = useEvaluationStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsCache = useWorkflowSettingsCache();
const uiStore = useUIStore();
const message = useMessage();
const telemetry = useTelemetry();
const sourceControlStore = useSourceControlStore();
const isPopoverOpen = ref(false);
const cachedSettings = ref<WorkflowSettings | null>(null);
const hasAINode = computed(() => {
const nodes = props.workflow.nodes;
return nodes.some((node) => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return nodeType?.codex?.categories?.includes('AI');
});
});
const hasEvaluationSetOutputsNode = computed((): boolean => {
return evaluationStore.evaluationSetOutputsNodeExist;
});
const hasErrorWorkflow = computed(() => {
return !!props.workflow.settings?.errorWorkflow;
});
const hasTimeSaved = computed(() => {
return props.workflow.settings?.timeSavedPerExecution !== undefined;
});
const isActivationModalOpen = computed(() => {
return uiStore.isModalActiveById[WORKFLOW_ACTIVE_MODAL_KEY];
});
const isProtectedEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly;
});
const availableActions = computed(() => {
if (!props.workflow.active || workflowsCache.isCacheLoading.value) {
return [];
}
const actions: Array<{
id: ActionType;
title: string;
description: string;
moreInfoLink: string;
completed: boolean;
}> = [];
const suggestedActionSettings = cachedSettings.value?.suggestedActions ?? {};
// Error workflow action
if (!suggestedActionSettings.errorWorkflow?.ignored) {
actions.push({
id: 'errorWorkflow',
title: i18n.baseText('workflowProductionChecklist.errorWorkflow.title'),
description: i18n.baseText('workflowProductionChecklist.errorWorkflow.description'),
moreInfoLink: ERROR_WORKFLOW_DOCS_URL,
completed: hasErrorWorkflow.value,
});
}
// Evaluations action
if (
hasAINode.value &&
evaluationStore.isEvaluationEnabled &&
!suggestedActionSettings.evaluations?.ignored
) {
actions.push({
id: 'evaluations',
title: i18n.baseText('workflowProductionChecklist.evaluations.title'),
description: i18n.baseText('workflowProductionChecklist.evaluations.description'),
moreInfoLink: EVALUATIONS_DOCS_URL,
completed: hasEvaluationSetOutputsNode.value,
});
}
// Time saved action
if (!suggestedActionSettings.timeSaved?.ignored) {
actions.push({
id: 'timeSaved',
title: i18n.baseText('workflowProductionChecklist.timeSaved.title'),
description: i18n.baseText('workflowProductionChecklist.timeSaved.description'),
moreInfoLink: TIME_SAVED_DOCS_URL,
completed: hasTimeSaved.value,
});
}
return actions;
});
async function loadWorkflowSettings() {
if (props.workflow.id) {
// todo add global config
cachedSettings.value = await workflowsCache.getMergedWorkflowSettings(props.workflow.id);
}
}
async function handleActionClick(actionId: string) {
if (actionId === 'evaluations') {
// Navigate to evaluations
await router.push({
name: VIEWS.EVALUATION_EDIT,
params: { name: props.workflow.id },
});
} else if (actionId === 'errorWorkflow' || actionId === 'timeSaved') {
// Open workflow settings modal
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
isPopoverOpen.value = false;
}
function isValidAction(action: string): action is ActionType {
return ['evaluations', 'errorWorkflow', 'timeSaved'].includes(action);
}
async function handleIgnoreClick(actionId: string) {
if (!isValidAction(actionId)) {
return;
}
await workflowsCache.ignoreSuggestedAction(props.workflow.id, actionId);
await loadWorkflowSettings();
telemetry.track('user clicked ignore suggested action', {
actionId,
});
}
async function handleIgnoreAll() {
const ignoreAllConfirmed = await message.confirm(
i18n.baseText('workflowProductionChecklist.ignoreAllConfirmation.description'),
i18n.baseText('workflowProductionChecklist.ignoreAllConfirmation.title'),
{
confirmButtonText: i18n.baseText('workflowProductionChecklist.ignoreAllConfirmation.confirm'),
},
);
if (ignoreAllConfirmed === MODAL_CONFIRM) {
await workflowsCache.ignoreAllSuggestedActionsForAllWorkflows(
availableActions.value.map((action) => action.id),
);
await loadWorkflowSettings();
telemetry.track('user clicked ignore suggested actions for all workflows');
}
}
function openSuggestedActions() {
isPopoverOpen.value = true;
}
function onPopoverOpened() {
telemetry.track('user opened suggested actions checklist');
}
function handlePopoverOpenChange(open: boolean) {
if (open) {
isPopoverOpen.value = true;
onPopoverOpened();
} else if (!isActivationModalOpen.value) {
isPopoverOpen.value = false;
}
}
// Watch for workflow activation
watch(
() => props.workflow.active,
async (isActive, wasActive) => {
if (isActive && !wasActive) {
// Check if this is the first activation
if (!cachedSettings.value?.firstActivatedAt) {
setTimeout(() => {
openSuggestedActions();
}, 0); // Ensure UI is ready and availableActions.length > 0
}
// Update firstActivatedAt after opening popover
await workflowsCache.updateFirstActivatedAt(props.workflow.id);
}
},
);
onMounted(async () => {
await loadWorkflowSettings();
});
</script>
<template>
<N8nSuggestedActions
v-if="availableActions.length > 0"
:open="isPopoverOpen"
:title="i18n.baseText('workflowProductionChecklist.title')"
:actions="availableActions"
:ignore-all-label="i18n.baseText('workflowProductionChecklist.turnOffWorkflowSuggestions')"
:notice="
isProtectedEnvironment ? i18n.baseText('workflowProductionChecklist.readOnlyNotice') : ''
"
popover-alignment="end"
@action-click="handleActionClick"
@ignore-click="handleIgnoreClick"
@ignore-all="handleIgnoreAll"
@update:open="handlePopoverOpenChange"
/>
</template>

View File

@@ -497,7 +497,7 @@ onMounted(async () => {
<div v-loading="isLoading" class="workflow-settings" data-test-id="workflow-settings-dialog">
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.executionOrder') + ':' }}
{{ i18n.baseText('workflowSettings.executionOrder') }}
</el-col>
<el-col :span="14" class="ignore-key-press-canvas">
<N8nSelect
@@ -522,7 +522,7 @@ onMounted(async () => {
<el-row data-test-id="error-workflow">
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.errorWorkflow') + ':' }}
{{ i18n.baseText('workflowSettings.errorWorkflow') }}
<N8nTooltip placement="top">
<template #content>
<div v-n8n-html="helpTexts.errorWorkflow"></div>
@@ -555,7 +555,7 @@ onMounted(async () => {
<div v-if="isSharingEnabled" data-test-id="workflow-caller-policy">
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.callerPolicy') + ':' }}
{{ i18n.baseText('workflowSettings.callerPolicy') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.workflowCallerPolicy"></div>
@@ -584,7 +584,7 @@ onMounted(async () => {
</el-row>
<el-row v-if="workflowSettings.callerPolicy === 'workflowsFromAList'">
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.callerIds') + ':' }}
{{ i18n.baseText('workflowSettings.callerIds') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.workflowCallerIds"></div>
@@ -606,7 +606,7 @@ onMounted(async () => {
</div>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timezone') + ':' }}
{{ i18n.baseText('workflowSettings.timezone') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.timezone"></div>
@@ -635,7 +635,7 @@ onMounted(async () => {
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveDataErrorExecution') + ':' }}
{{ i18n.baseText('workflowSettings.saveDataErrorExecution') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.saveDataErrorExecution"></div>
@@ -664,7 +664,7 @@ onMounted(async () => {
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveDataSuccessExecution') + ':' }}
{{ i18n.baseText('workflowSettings.saveDataSuccessExecution') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.saveDataSuccessExecution"></div>
@@ -693,7 +693,7 @@ onMounted(async () => {
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveManualExecutions') + ':' }}
{{ i18n.baseText('workflowSettings.saveManualExecutions') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.saveManualExecutions"></div>
@@ -722,7 +722,7 @@ onMounted(async () => {
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveExecutionProgress') + ':' }}
{{ i18n.baseText('workflowSettings.saveExecutionProgress') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.saveExecutionProgress"></div>
@@ -751,7 +751,7 @@ onMounted(async () => {
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timeoutWorkflow') + ':' }}
{{ i18n.baseText('workflowSettings.timeoutWorkflow') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.executionTimeoutToggle"></div>
@@ -778,7 +778,7 @@ onMounted(async () => {
>
<el-row>
<el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timeoutAfter') + ':' }}
{{ i18n.baseText('workflowSettings.timeoutAfter') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.executionTimeout"></div>
@@ -823,7 +823,7 @@ onMounted(async () => {
<el-row>
<el-col :span="10" class="setting-name">
<label for="timeSavedPerExecution">
{{ i18n.baseText('workflowSettings.timeSavedPerExecution') + ':' }}
{{ i18n.baseText('workflowSettings.timeSavedPerExecution') }}
<N8nTooltip placement="top">
<template #content>
{{ i18n.baseText('workflowSettings.timeSavedPerExecution.tooltip') }}

View File

@@ -0,0 +1,365 @@
import { vi } from 'vitest';
import {
useWorkflowSettingsCache,
type ActionType,
type WorkflowSettings,
} from './useWorkflowsCache';
// Mock the cache plugin
const mockCache = {
getItem: vi.fn(),
setItem: vi.fn(),
};
vi.mock('@/plugins/cache', () => ({
indexedDbCache: vi.fn(async () => await Promise.resolve(mockCache)),
}));
describe('useWorkflowSettingsCache', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCache.getItem.mockReturnValue(null);
mockCache.setItem.mockClear();
});
describe('basic functionality', () => {
it('should initialize with loading state', async () => {
const { isCacheLoading } = useWorkflowSettingsCache();
expect(isCacheLoading.value).toBe(true); // Initially loading
// Wait for cache promise to resolve
await vi.waitFor(() => {
expect(isCacheLoading.value).toBe(false);
});
});
it('should get workflow settings from empty cache', async () => {
const { getWorkflowSettings } = useWorkflowSettingsCache();
const settings = await getWorkflowSettings('workflow-1');
expect(mockCache.getItem).toHaveBeenCalledWith('workflow-1');
expect(settings).toEqual({});
});
it('should get existing workflow settings from cache', async () => {
const existingSettings: WorkflowSettings = {
firstActivatedAt: 123456789,
suggestedActions: {
evaluations: { ignored: true },
},
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const { getWorkflowSettings } = useWorkflowSettingsCache();
const settings = await getWorkflowSettings('workflow-1');
expect(settings).toEqual(existingSettings);
});
it('should handle malformed JSON gracefully', async () => {
mockCache.getItem.mockReturnValue('invalid-json{');
const { getWorkflowSettings } = useWorkflowSettingsCache();
const settings = await getWorkflowSettings('workflow-1');
expect(settings).toEqual({});
});
});
describe('getMergedWorkflowSettings', () => {
it('should return workflow settings when no global preferences exist', async () => {
const workflowSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: true },
},
};
mockCache.getItem
.mockReturnValueOnce(JSON.stringify(workflowSettings)) // workflow settings
.mockReturnValueOnce(null); // global settings
const { getMergedWorkflowSettings } = useWorkflowSettingsCache();
const merged = await getMergedWorkflowSettings('workflow-1');
expect(merged).toEqual(workflowSettings);
expect(mockCache.getItem).toHaveBeenCalledWith('workflow-1');
expect(mockCache.getItem).toHaveBeenCalledWith('*');
});
it('should merge workflow and global suggested actions', async () => {
const workflowSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: true },
},
};
const globalSettings: WorkflowSettings = {
suggestedActions: {
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
},
};
mockCache.getItem
.mockReturnValueOnce(JSON.stringify(workflowSettings))
.mockReturnValueOnce(JSON.stringify(globalSettings));
const { getMergedWorkflowSettings } = useWorkflowSettingsCache();
const merged = await getMergedWorkflowSettings('workflow-1');
expect(merged.suggestedActions).toEqual({
evaluations: { ignored: true },
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
});
});
it('should prioritize global settings over workflow settings', async () => {
const workflowSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: false },
},
};
const globalSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: true },
},
};
mockCache.getItem
.mockReturnValueOnce(JSON.stringify(workflowSettings))
.mockReturnValueOnce(JSON.stringify(globalSettings));
const { getMergedWorkflowSettings } = useWorkflowSettingsCache();
const merged = await getMergedWorkflowSettings('workflow-1');
expect(merged.suggestedActions?.evaluations?.ignored).toBe(true);
});
});
describe('upsertWorkflowSettings', () => {
it('should create new workflow settings', async () => {
const updates: Partial<WorkflowSettings> = {
firstActivatedAt: 123456789,
};
const { upsertWorkflowSettings } = useWorkflowSettingsCache();
await upsertWorkflowSettings('workflow-1', updates);
expect(mockCache.setItem).toHaveBeenCalledWith('workflow-1', JSON.stringify(updates));
});
it('should update existing workflow settings', async () => {
const existingSettings: WorkflowSettings = {
firstActivatedAt: 123456789,
suggestedActions: {
evaluations: { ignored: true },
},
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const updates: Partial<WorkflowSettings> = {
evaluationRuns: {
order: ['run1', 'run2'],
visibility: { run1: true, run2: false },
},
};
const { upsertWorkflowSettings } = useWorkflowSettingsCache();
await upsertWorkflowSettings('workflow-1', updates);
const expectedSettings = {
...existingSettings,
...updates,
};
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify(expectedSettings),
);
});
it('should deep merge suggested actions', async () => {
const existingSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: true },
errorWorkflow: { ignored: false },
},
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const updates: Partial<WorkflowSettings> = {
suggestedActions: {
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
},
};
const { upsertWorkflowSettings } = useWorkflowSettingsCache();
await upsertWorkflowSettings('workflow-1', updates);
const expectedSettings: WorkflowSettings = {
suggestedActions: {
evaluations: { ignored: true },
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
},
};
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify(expectedSettings),
);
});
});
describe('updateFirstActivatedAt', () => {
it('should set firstActivatedAt when not present', async () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const { updateFirstActivatedAt } = useWorkflowSettingsCache();
await updateFirstActivatedAt('workflow-1');
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify({ firstActivatedAt: now }),
);
});
it('should not overwrite existing firstActivatedAt', async () => {
const existingSettings: WorkflowSettings = {
firstActivatedAt: 123456789,
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const { updateFirstActivatedAt } = useWorkflowSettingsCache();
await updateFirstActivatedAt('workflow-1');
expect(mockCache.setItem).not.toHaveBeenCalled();
});
});
describe('suggested actions', () => {
it('should ignore suggested action for specific workflow', async () => {
const { ignoreSuggestedAction } = useWorkflowSettingsCache();
await ignoreSuggestedAction('workflow-1', 'evaluations');
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify({
suggestedActions: {
evaluations: { ignored: true },
},
}),
);
});
it('should ignore all suggested actions globally', async () => {
const actionsToIgnore: ActionType[] = ['evaluations', 'errorWorkflow', 'timeSaved'];
const { ignoreAllSuggestedActionsForAllWorkflows } = useWorkflowSettingsCache();
await ignoreAllSuggestedActionsForAllWorkflows(actionsToIgnore);
expect(mockCache.setItem).toHaveBeenCalledWith(
'*',
JSON.stringify({
suggestedActions: {
evaluations: { ignored: true },
errorWorkflow: { ignored: true },
timeSaved: { ignored: true },
},
}),
);
});
});
describe('evaluation preferences', () => {
it('should get default evaluation preferences when none exist', async () => {
const { getEvaluationPreferences } = useWorkflowSettingsCache();
const prefs = await getEvaluationPreferences('workflow-1');
expect(prefs).toEqual({
order: [],
visibility: {},
});
});
it('should get existing evaluation preferences', async () => {
const existingSettings: WorkflowSettings = {
evaluationRuns: {
order: ['run1', 'run2'],
visibility: { run1: true, run2: false },
},
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const { getEvaluationPreferences } = useWorkflowSettingsCache();
const prefs = await getEvaluationPreferences('workflow-1');
expect(prefs).toEqual(existingSettings.evaluationRuns);
});
it('should save evaluation preferences', async () => {
const evaluationRuns = {
order: ['run1', 'run2', 'run3'],
visibility: { run1: true, run2: true, run3: false },
};
const { saveEvaluationPreferences } = useWorkflowSettingsCache();
await saveEvaluationPreferences('workflow-1', evaluationRuns);
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify({ evaluationRuns }),
);
});
});
describe('edge cases', () => {
it('should handle concurrent operations correctly', async () => {
const { getWorkflowSettings, upsertWorkflowSettings } = useWorkflowSettingsCache();
// Simulate concurrent operations
const promises = [
getWorkflowSettings('workflow-1'),
upsertWorkflowSettings('workflow-1', { firstActivatedAt: 123 }),
getWorkflowSettings('workflow-1'),
];
await Promise.all(promises);
expect(mockCache.getItem).toHaveBeenCalledTimes(3); // 2 direct gets + 1 from upsert
expect(mockCache.setItem).toHaveBeenCalledTimes(1);
});
it('should handle empty string from cache', async () => {
mockCache.getItem.mockReturnValue('');
const { getWorkflowSettings } = useWorkflowSettingsCache();
const settings = await getWorkflowSettings('workflow-1');
expect(settings).toEqual({});
});
it('should handle undefined suggestedActions in updates', async () => {
const existingSettings: WorkflowSettings = {
firstActivatedAt: 123456789,
suggestedActions: {
evaluations: { ignored: true },
},
};
mockCache.getItem.mockReturnValue(JSON.stringify(existingSettings));
const updates: Partial<WorkflowSettings> = {
suggestedActions: undefined,
};
const { upsertWorkflowSettings } = useWorkflowSettingsCache();
await upsertWorkflowSettings('workflow-1', updates);
// suggestedActions will be overwritten with undefined due to spread operator
expect(mockCache.setItem).toHaveBeenCalledWith(
'workflow-1',
JSON.stringify({
firstActivatedAt: 123456789,
suggestedActions: undefined,
}),
);
});
});
});

View File

@@ -0,0 +1,139 @@
import { indexedDbCache } from '@/plugins/cache';
import { jsonParse } from 'n8n-workflow';
import { ref } from 'vue';
const actionTypes = ['evaluations', 'errorWorkflow', 'timeSaved'] as const;
export type ActionType = (typeof actionTypes)[number];
export interface UserEvaluationPreferences {
order: string[];
visibility: Record<string, boolean>;
}
export interface WorkflowSettings {
firstActivatedAt?: number;
suggestedActions?: {
[K in ActionType]?: { ignored: boolean };
};
evaluationRuns?: UserEvaluationPreferences;
}
export function useWorkflowSettingsCache() {
const isCacheLoading = ref<boolean>(true);
const cachePromise = ref(
indexedDbCache('n8n-local', 'workflows').finally(() => {
isCacheLoading.value = false;
}),
);
async function getWorkflowsCache() {
return await cachePromise.value;
}
async function getWorkflowSettings(workflowId: string): Promise<WorkflowSettings> {
const cache = await getWorkflowsCache();
return jsonParse<WorkflowSettings>(cache.getItem(workflowId) ?? '', {
fallbackValue: {},
});
}
async function getMergedWorkflowSettings(workflowId: string): Promise<WorkflowSettings> {
const workflowSettings = await getWorkflowSettings(workflowId);
const cache = await getWorkflowsCache();
const globalPreferences = jsonParse<WorkflowSettings>(cache.getItem('*') ?? '', {
fallbackValue: {},
});
workflowSettings.suggestedActions = {
...(workflowSettings.suggestedActions ?? {}),
...(globalPreferences.suggestedActions ?? {}),
};
return workflowSettings;
}
async function upsertWorkflowSettings(
workflowId: string,
updates: Partial<WorkflowSettings>,
): Promise<void> {
const cache = await getWorkflowsCache();
const existingSettings = await getWorkflowSettings(workflowId);
const updatedSettings: WorkflowSettings = {
...existingSettings,
...updates,
};
// Deep merge suggestedActions if provided
if (updates.suggestedActions) {
updatedSettings.suggestedActions = {
...(existingSettings.suggestedActions ?? {}),
...updates.suggestedActions,
};
}
cache.setItem(workflowId, JSON.stringify(updatedSettings));
}
async function updateFirstActivatedAt(workflowId: string): Promise<void> {
const existingSettings = await getWorkflowSettings(workflowId);
// Only update if not already set
if (!existingSettings?.firstActivatedAt) {
await upsertWorkflowSettings(workflowId, {
firstActivatedAt: Date.now(),
});
}
}
async function ignoreSuggestedAction(workflowId: string, action: ActionType): Promise<void> {
await upsertWorkflowSettings(workflowId, {
suggestedActions: {
[action]: { ignored: true },
},
});
}
async function getEvaluationPreferences(workflowId: string): Promise<UserEvaluationPreferences> {
return (
(await getWorkflowSettings(workflowId))?.evaluationRuns ?? {
order: [],
visibility: {},
}
);
}
async function saveEvaluationPreferences(
workflowId: string,
evaluationRuns: UserEvaluationPreferences,
): Promise<void> {
await upsertWorkflowSettings(workflowId, { evaluationRuns });
}
async function ignoreAllSuggestedActionsForAllWorkflows(actionsToIgnore: ActionType[]) {
await upsertWorkflowSettings(
'*',
actionsToIgnore.reduce<WorkflowSettings>((accu, key) => {
accu.suggestedActions = accu.suggestedActions ?? {};
accu.suggestedActions[key] = {
ignored: true,
};
return accu;
}, {}),
);
}
return {
getWorkflowSettings,
getMergedWorkflowSettings,
upsertWorkflowSettings,
updateFirstActivatedAt,
ignoreSuggestedAction,
ignoreAllSuggestedActionsForAllWorkflows,
getEvaluationPreferences,
saveEvaluationPreferences,
isCacheLoading,
};
}

View File

@@ -115,6 +115,9 @@ export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://${DOCS_DOMAIN}/integratio
export const COMMUNITY_NODES_BLOCKLIST_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/blocklist/`;
export const CUSTOM_NODES_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/creating-nodes/code/create-n8n-nodes-module/`;
export const EXPRESSIONS_DOCS_URL = `https://${DOCS_DOMAIN}/code-examples/expressions/`;
export const EVALUATIONS_DOCS_URL = `https://${DOCS_DOMAIN}/advanced-ai/evaluations/overview/`;
export const ERROR_WORKFLOW_DOCS_URL = `https://${DOCS_DOMAIN}/flow-logic/error-handling/#create-and-set-an-error-workflow`;
export const TIME_SAVED_DOCS_URL = `https://${DOCS_DOMAIN}/insights/#setting-the-time-saved-by-a-workflow`;
export const N8N_PRICING_PAGE_URL = 'https://n8n.io/pricing';
export const N8N_MAIN_GITHUB_REPO_URL = 'https://github.com/n8n-io/n8n';
@@ -489,6 +492,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
export const LOCAL_STORAGE_TURN_OFF_WORKFLOW_SUGGESTIONS = 'N8N_TURN_OFF_WORKFLOW_SUGGESTIONS';
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLED';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';

View File

@@ -2,7 +2,7 @@
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUsageStore } from '@/stores/usage.store';
import { useAsyncState } from '@vueuse/core';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, EVALUATIONS_DOCS_URL } from '@/constants';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
@@ -136,7 +136,7 @@ watch(
</N8nText>
<N8nText tag="p" size="small" color="text-base" :class="$style.description">
{{ locale.baseText('evaluations.setupWizard.description') }}
<N8nLink size="small" href="https://docs.n8n.io/advanced-ai/evaluations/overview">{{
<N8nLink size="small" :href="EVALUATIONS_DOCS_URL">{{
locale.baseText('evaluations.setupWizard.moreInfo')
}}</N8nLink>
</N8nText>

View File

@@ -28,8 +28,10 @@ import {
getTestCasesColumns,
getTestTableHeaders,
} from './utils';
import { indexedDbCache } from '@/plugins/cache';
import { jsonParse } from 'n8n-workflow';
import {
useWorkflowSettingsCache,
type UserEvaluationPreferences,
} from '@/composables/useWorkflowsCache';
export type Column =
| {
@@ -44,11 +46,6 @@ export type Column =
// even if some columns are disabled / not available in the current run
| { key: string; disabled: true };
interface UserPreferences {
order: string[];
visibility: Record<string, boolean>;
}
export type Header = TestTableColumn<TestCaseExecutionRecord & { index: number }>;
const router = useRouter();
@@ -56,6 +53,7 @@ const toast = useToast();
const evaluationStore = useEvaluationStore();
const workflowsStore = useWorkflowsStore();
const locale = useI18n();
const workflowsCache = useWorkflowSettingsCache();
const isLoading = ref(true);
const testCases = ref<TestCaseExecutionRecord[]>([]);
@@ -65,7 +63,7 @@ const runId = computed(() => router.currentRoute.value.params.runId as string);
const workflowId = computed(() => router.currentRoute.value.params.name as string);
const workflowName = computed(() => workflowsStore.getWorkflowById(workflowId.value)?.name ?? '');
const cachedUserPreferences = ref<UserPreferences | undefined>();
const cachedUserPreferences = ref<UserEvaluationPreferences | undefined>();
const expandedRows = ref<Set<string>>(new Set());
const run = computed(() => evaluationStore.testRunsById[runId.value]);
@@ -158,18 +156,13 @@ const fetchExecutionTestCases = async () => {
};
async function loadCachedUserPreferences() {
const cache = await indexedDbCache('workflows', 'evaluations');
cachedUserPreferences.value = jsonParse(cache.getItem(workflowId.value) ?? '', {
fallbackValue: {
order: [],
visibility: {},
},
});
cachedUserPreferences.value = await workflowsCache.getEvaluationPreferences(workflowId.value);
}
async function saveCachedUserPreferences() {
const cache = await indexedDbCache('workflows', 'evaluations');
cache.setItem(workflowId.value, JSON.stringify(cachedUserPreferences.value));
if (cachedUserPreferences.value) {
await workflowsCache.saveEvaluationPreferences(workflowId.value, cachedUserPreferences.value);
}
}
async function handleColumnVisibilityUpdate(columnKey: string, visibility: boolean) {