mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
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:
@@ -260,6 +260,57 @@ Cypress.Commands.add('resetDatabase', () => {
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clearIndexedDB', (dbName: string, storeName?: string) => {
|
||||
cy.window().then((win) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!win.indexedDB) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// If storeName is provided, clear specific store; otherwise delete entire database
|
||||
if (storeName) {
|
||||
const openRequest = win.indexedDB.open(dbName);
|
||||
|
||||
openRequest.onsuccess = () => {
|
||||
const db = openRequest.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
db.close();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = db.transaction([storeName], 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const clearRequest = store.clear();
|
||||
|
||||
clearRequest.onsuccess = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
clearRequest.onerror = () => {
|
||||
db.close();
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
reject(clearRequest.error);
|
||||
};
|
||||
};
|
||||
|
||||
openRequest.onerror = () => {
|
||||
resolve(); // Database doesn't exist, nothing to clear
|
||||
};
|
||||
} else {
|
||||
const deleteRequest = win.indexedDB.deleteDatabase(dbName);
|
||||
|
||||
deleteRequest.onsuccess = () => resolve();
|
||||
deleteRequest.onerror = () => resolve(); // Ignore errors if DB doesn't exist
|
||||
deleteRequest.onblocked = () => resolve(); // Ignore if blocked
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('interceptNewTab', () => {
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, 'open').as('windowOpen');
|
||||
|
||||
@@ -90,6 +90,7 @@ declare global {
|
||||
}
|
||||
>;
|
||||
resetDatabase(): void;
|
||||
clearIndexedDB(dbName: string, storeName?: string): Chainable<void>;
|
||||
setAppDate(targetDate: number | Date): void;
|
||||
interceptNewTab(): Chainable<void>;
|
||||
visitInterceptedTab(): Chainable<void>;
|
||||
|
||||
@@ -145,6 +145,33 @@
|
||||
--button-loading-background-color: var(--color-button-secondary-loading-background);
|
||||
}
|
||||
|
||||
@mixin n8n-button-highlight {
|
||||
--button-font-color: var(--color-button-highlight-font);
|
||||
--button-border-color: var(--color-button-highlight-border);
|
||||
--button-background-color: var(--color-button-highlight-background);
|
||||
|
||||
--button-hover-font-color: var(--color-button-highlight-hover-active-focus-font);
|
||||
--button-hover-border-color: var(--color-button-highlight-hover-active-focus-border);
|
||||
--button-hover-background-color: var(--color-button-highlight-hover-background);
|
||||
|
||||
--button-active-font-color: var(--color-button-highlight-hover-active-focus-font);
|
||||
--button-active-border-color: var(--color-button-highlight-hover-active-focus-border);
|
||||
--button-active-background-color: var(--color-button-highlight-active-focus-background);
|
||||
|
||||
--button-focus-font-color: var(--color-button-highlight-hover-active-focus-font);
|
||||
--button-focus-border-color: var(--color-button-highlight-hover-active-focus-border);
|
||||
--button-focus-background-color: var(--color-button-highlight-active-focus-background);
|
||||
--button-focus-outline-color: var(--color-button-highlight-focus-outline);
|
||||
|
||||
--button-disabled-font-color: var(--color-button-highlight-disabled-font);
|
||||
--button-disabled-border-color: var(--color-button-highlight-disabled-border);
|
||||
--button-disabled-background-color: var(--color-button-highlight-disabled-background);
|
||||
|
||||
--button-loading-font-color: var(--color-button-highlight-loading-font);
|
||||
--button-loading-border-color: var(--color-button-highlight-loading-border);
|
||||
--button-loading-background-color: var(--color-button-highlight-loading-background);
|
||||
}
|
||||
|
||||
@mixin n8n-button-success {
|
||||
--button-font-color: var(--color-button-success-font);
|
||||
--button-border-color: var(--color-success);
|
||||
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'],
|
||||
options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger', 'highlight'],
|
||||
},
|
||||
size: {
|
||||
control: {
|
||||
@@ -78,6 +78,7 @@ const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({
|
||||
<n8n-button v-bind="args" size="large" type="success" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="large" type="warning" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="large" type="danger" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="large" type="highlight" @click="onClick" />
|
||||
<br/>
|
||||
<br/>
|
||||
<n8n-button v-bind="args" size="medium" type="primary" @click="onClick" />
|
||||
@@ -86,6 +87,7 @@ const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({
|
||||
<n8n-button v-bind="args" size="medium" type="success" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="medium" type="warning" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="medium" type="danger" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="medium" type="highlight" @click="onClick" />
|
||||
<br/>
|
||||
<br/>
|
||||
<n8n-button v-bind="args" size="small" type="primary" @click="onClick" />
|
||||
@@ -94,6 +96,7 @@ const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({
|
||||
<n8n-button v-bind="args" size="small" type="success" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="small" type="warning" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="small" type="danger" @click="onClick" />
|
||||
<n8n-button v-bind="args" size="small" type="highlight" @click="onClick" />
|
||||
</div>`,
|
||||
methods,
|
||||
});
|
||||
@@ -152,6 +155,12 @@ WithIcon.args = {
|
||||
icon: 'circle-plus',
|
||||
};
|
||||
|
||||
export const Highlight = AllSizesTemplate.bind({});
|
||||
Highlight.args = {
|
||||
type: 'highlight',
|
||||
label: 'Button',
|
||||
};
|
||||
|
||||
export const Square = AllColorsAndSizesTemplate.bind({});
|
||||
Square.args = {
|
||||
label: '48',
|
||||
|
||||
@@ -64,6 +64,23 @@ describe('components', () => {
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it('should render highlight button', () => {
|
||||
const wrapper = render(N8nButton, {
|
||||
props: {
|
||||
type: 'highlight',
|
||||
},
|
||||
slots,
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button?.className).toContain('highlight');
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,6 +120,10 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0);
|
||||
@include Button.n8n-button-secondary;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
@include Button.n8n-button-highlight;
|
||||
}
|
||||
|
||||
.tertiary {
|
||||
@include Button.n8n-button-secondary;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ exports[`components > N8nButton > props > square > should render square button 1
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`components > N8nButton > props > type > should render highlight button 1`] = `
|
||||
"<button class="button button highlight medium" aria-live="polite">
|
||||
<!--v-if-->Button
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`components > N8nButton > should render correctly 1`] = `
|
||||
"<button class="button button primary medium" aria-live="polite">
|
||||
<!--v-if-->Button
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import N8nLink from './Link.vue';
|
||||
|
||||
describe('N8nLink', () => {
|
||||
it('should render internal router links', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: '/test',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'Test Link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render external links', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: 'https://example.com/',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'External Link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render title attribute on external links', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: 'https://example.com/',
|
||||
title: 'Visit Example Website',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'External Link',
|
||||
},
|
||||
});
|
||||
const linkElement = wrapper.getByRole('link');
|
||||
expect(linkElement).toHaveAttribute('title', 'Visit Example Website');
|
||||
});
|
||||
|
||||
it('should render title attribute on internal links with newWindow=true', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: '/internal',
|
||||
newWindow: true,
|
||||
title: 'Open in New Window',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'Internal Link',
|
||||
},
|
||||
});
|
||||
const linkElement = wrapper.getByRole('link');
|
||||
expect(linkElement).toHaveAttribute('title', 'Open in New Window');
|
||||
});
|
||||
|
||||
it('should render with different themes', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: '/test',
|
||||
theme: 'danger',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'Danger Link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with underline', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: '/test',
|
||||
underline: true,
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'Underlined Link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with bold text', () => {
|
||||
const wrapper = render(N8nLink, {
|
||||
props: {
|
||||
to: '/test',
|
||||
bold: true,
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
slots: {
|
||||
default: 'Bold Link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ interface LinkProps {
|
||||
bold?: boolean;
|
||||
underline?: boolean;
|
||||
theme?: (typeof THEME)[number];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'N8nLink' });
|
||||
@@ -28,7 +29,7 @@ withDefaults(defineProps<LinkProps>(), {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nRoute :to="to" :new-window="newWindow" v-bind="$attrs" class="n8n-link">
|
||||
<N8nRoute :to="to" :title="title" :new-window="newWindow" v-bind="$attrs" class="n8n-link">
|
||||
<span :class="$style[`${underline ? `${theme}-underline` : theme}`]">
|
||||
<N8nText :size="size" :bold="bold">
|
||||
<slot></slot>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`N8nLink > should render external links 1`] = `"<a href="https://example.com/" target="_blank" class="n8n-link"><span class="primary"><span class="n8n-text size-medium regular">External Link</span></span></a>"`;
|
||||
|
||||
exports[`N8nLink > should render internal router links 1`] = `"<router-link-stub to="/test" replace="false" custom="false" ariacurrentvalue="page" role="link" class="n8n-link"></router-link-stub>"`;
|
||||
|
||||
exports[`N8nLink > should render with bold text 1`] = `"<router-link-stub to="/test" replace="false" custom="false" ariacurrentvalue="page" role="link" class="n8n-link"></router-link-stub>"`;
|
||||
|
||||
exports[`N8nLink > should render with different themes 1`] = `"<router-link-stub to="/test" replace="false" custom="false" ariacurrentvalue="page" role="link" class="n8n-link"></router-link-stub>"`;
|
||||
|
||||
exports[`N8nLink > should render with underline 1`] = `"<router-link-stub to="/test" replace="false" custom="false" ariacurrentvalue="page" role="link" class="n8n-link"></router-link-stub>"`;
|
||||
@@ -21,6 +21,10 @@ interface Props {
|
||||
* Popover max height
|
||||
*/
|
||||
maxHeight?: string;
|
||||
/**
|
||||
* The preferred alignment against the trigger. May change when collisions occur.
|
||||
*/
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -33,6 +37,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
width: undefined,
|
||||
enableScrolling: true,
|
||||
scrollType: 'hover',
|
||||
align: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -44,7 +49,7 @@ const emit = defineEmits<Emits>();
|
||||
<slot name="trigger"></slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent side="bottom" :side-offset="5" :class="$style.popoverContent">
|
||||
<PopoverContent side="bottom" :align="align" :side-offset="5" :class="$style.popoverContent">
|
||||
<N8nScrollArea
|
||||
v-if="enableScrolling"
|
||||
:max-height="props.maxHeight"
|
||||
@@ -52,11 +57,11 @@ const emit = defineEmits<Emits>();
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
>
|
||||
<div :class="$style.contentContainer" :style="{ width }">
|
||||
<div :style="{ width }">
|
||||
<slot name="content" :close="() => emit('update:open', false)" />
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<div v-else :class="$style.contentContainer" :style="{ width }">
|
||||
<div v-else :style="{ width }">
|
||||
<slot name="content" :close="() => emit('update:open', false)" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -75,11 +80,7 @@ const emit = defineEmits<Emits>();
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform, opacity;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
padding: var(--spacing-s);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'] {
|
||||
|
||||
@@ -8,7 +8,7 @@ exports[`N8nPopoverReka > should render correctly with default props 1`] = `
|
||||
<div dir="ltr" style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;" class="scrollAreaRoot">
|
||||
<div data-reka-scroll-area-viewport="" style="overflow-x: hidden; overflow-y: hidden;" class="viewport" tabindex="0">
|
||||
<div>
|
||||
<div class="contentContainer">
|
||||
<div>
|
||||
<content></content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,4 +39,33 @@ describe('N8nRoute', () => {
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render title attribute on external links', () => {
|
||||
const wrapper = render(N8nRoute, {
|
||||
props: {
|
||||
to: 'https://example.com/',
|
||||
title: 'Visit Example',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
});
|
||||
const linkElement = wrapper.getByRole('link');
|
||||
expect(linkElement).toHaveAttribute('title', 'Visit Example');
|
||||
});
|
||||
|
||||
it('should render title attribute on internal links with newWindow=true', () => {
|
||||
const wrapper = render(N8nRoute, {
|
||||
props: {
|
||||
to: '/test',
|
||||
newWindow: true,
|
||||
title: 'Internal Link in New Window',
|
||||
},
|
||||
global: {
|
||||
stubs: ['RouterLink'],
|
||||
},
|
||||
});
|
||||
const linkElement = wrapper.getByRole('link');
|
||||
expect(linkElement).toHaveAttribute('title', 'Internal Link in New Window');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RouterLink, type RouteLocationRaw } from 'vue-router';
|
||||
interface RouteProps {
|
||||
to?: RouteLocationRaw | string;
|
||||
newWindow?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'N8nRoute' });
|
||||
@@ -35,6 +36,7 @@ const openNewWindow = computed(() => !useRouterLink.value);
|
||||
:href="to ? `${to}` : undefined"
|
||||
:target="openNewWindow ? '_blank' : '_self'"
|
||||
v-bind="$attrs"
|
||||
:title="title"
|
||||
>
|
||||
<slot></slot>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { StoryFn } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { SuggestedActionsProps } from './SuggestedActions.vue';
|
||||
import N8nSuggestedActions from './SuggestedActions.vue';
|
||||
|
||||
export default {
|
||||
title: 'Modules/SuggestedActions',
|
||||
component: N8nSuggestedActions,
|
||||
argTypes: {
|
||||
open: {
|
||||
control: 'boolean',
|
||||
description: 'Controls whether the popover is open',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup() {
|
||||
const isOpen = ref(false);
|
||||
|
||||
const onActionClick = (actionId: string) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
alert(`Action clicked: ${actionId}`);
|
||||
};
|
||||
|
||||
const onIgnoreClick = (actionId: string) => {
|
||||
console.log('Ignore clicked:', actionId);
|
||||
alert(`Ignore clicked: ${actionId}`);
|
||||
};
|
||||
|
||||
const onUpdateOpen = (open: boolean) => {
|
||||
console.log('Open state changed:', open);
|
||||
isOpen.value = open;
|
||||
};
|
||||
|
||||
return {
|
||||
args,
|
||||
isOpen,
|
||||
onActionClick,
|
||||
onIgnoreClick,
|
||||
onUpdateOpen,
|
||||
};
|
||||
},
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nSuggestedActions,
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 50px;">
|
||||
<p style="margin-bottom: 20px;">Popover is: {{ isOpen ? 'Open' : 'Closed' }}</p>
|
||||
<N8nSuggestedActions
|
||||
v-bind="args"
|
||||
v-model:open="isOpen"
|
||||
@action-click="onActionClick"
|
||||
@ignore-click="onIgnoreClick"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'evaluate-workflow',
|
||||
title: 'Evaluate your workflow with a dataset',
|
||||
description: 'Set up an AI evaluation to be sure th WF is reliable.',
|
||||
moreInfoLink: 'https://docs.n8n.io/evaluations',
|
||||
},
|
||||
{
|
||||
id: 'track-errors',
|
||||
title: 'Keep track of execution errors',
|
||||
description: 'Setup a workflow error to track what is going on here.',
|
||||
moreInfoLink: 'https://docs.n8n.io/error-workflows',
|
||||
},
|
||||
{
|
||||
id: 'track-time',
|
||||
title: 'Track the time you save',
|
||||
description:
|
||||
'Log how much time this workflow saves each run to measure your automation impact.',
|
||||
},
|
||||
],
|
||||
title: 'Suggested Actions',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
export const InitiallyOpen = Template.bind({});
|
||||
InitiallyOpen.args = {
|
||||
...Default.args,
|
||||
open: true,
|
||||
};
|
||||
|
||||
export const WithoutMoreInfoLinks = Template.bind({});
|
||||
WithoutMoreInfoLinks.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'action-1',
|
||||
title: 'Action without more info link',
|
||||
description: 'This action does not have a more info link.',
|
||||
},
|
||||
{
|
||||
id: 'action-2',
|
||||
title: 'Another action',
|
||||
description: 'This one also lacks a more info link.',
|
||||
},
|
||||
],
|
||||
title: 'Actions Without Links',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
export const LongContent = Template.bind({});
|
||||
LongContent.args = {
|
||||
actions: Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `action-${i + 1}`,
|
||||
title: `Suggested Action ${i + 1}`,
|
||||
description: `This is a longer description for action ${i + 1} that demonstrates how the component handles scrolling when there are many items in the list.`,
|
||||
moreInfoLink: i % 2 === 0 ? 'https://docs.n8n.io' : undefined,
|
||||
})),
|
||||
title: 'Many Actions',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
const TemplateWithEvents: StoryFn = (args, { argTypes }) => ({
|
||||
setup() {
|
||||
const isOpen = ref(false);
|
||||
|
||||
const onActionClick = (actionId: string) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
alert(`Action clicked: ${actionId}`);
|
||||
};
|
||||
|
||||
const onIgnoreClick = (actionId: string) => {
|
||||
console.log('Ignore clicked:', actionId);
|
||||
alert(`Ignore clicked: ${actionId}`);
|
||||
};
|
||||
|
||||
const onIgnoreAll = () => {
|
||||
console.log('Ignore all clicked');
|
||||
alert('Ignore all clicked');
|
||||
};
|
||||
|
||||
const onUpdateOpen = (open: boolean) => {
|
||||
console.log('Open state changed:', open);
|
||||
isOpen.value = open;
|
||||
};
|
||||
|
||||
return {
|
||||
args,
|
||||
isOpen,
|
||||
onActionClick,
|
||||
onIgnoreClick,
|
||||
onIgnoreAll,
|
||||
onUpdateOpen,
|
||||
};
|
||||
},
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nSuggestedActions,
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 50px;">
|
||||
<p style="margin-bottom: 20px;">Popover is: {{ isOpen ? 'Open' : 'Closed' }}</p>
|
||||
<N8nSuggestedActions
|
||||
v-bind="args"
|
||||
v-model:open="isOpen"
|
||||
@action-click="onActionClick"
|
||||
@ignore-click="onIgnoreClick"
|
||||
@ignore-all="onIgnoreAll"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const WithIgnoreAllOption = TemplateWithEvents.bind({});
|
||||
WithIgnoreAllOption.args = {
|
||||
title: 'Suggested Actions',
|
||||
ignoreAllLabel: 'Ignore for all workflows',
|
||||
open: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'evaluate-workflow',
|
||||
title: 'Evaluate your workflow with a dataset',
|
||||
description: 'Set up an AI evaluation to be sure th WF is reliable.',
|
||||
moreInfoLink: 'https://docs.n8n.io/evaluations',
|
||||
},
|
||||
{
|
||||
id: 'track-errors',
|
||||
title: 'Keep track of execution errors',
|
||||
description: 'Setup a workflow error to track what is going on here.',
|
||||
moreInfoLink: 'https://docs.n8n.io/error-workflows',
|
||||
},
|
||||
],
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
export const SingleActionWithTurnOff = TemplateWithEvents.bind({});
|
||||
SingleActionWithTurnOff.args = {
|
||||
ignoreAllLabel: 'Disable all suggestions',
|
||||
title: 'Single Action',
|
||||
open: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'single-action',
|
||||
title: 'Single action with turn off option',
|
||||
description: 'This shows how the turn off option appears even with a single action.',
|
||||
},
|
||||
],
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
const AlignmentTemplate: StoryFn = (args, { argTypes }) => ({
|
||||
setup() {
|
||||
const startOpen = ref(false);
|
||||
const centerOpen = ref(false);
|
||||
const endOpen = ref(false);
|
||||
|
||||
return {
|
||||
args,
|
||||
startOpen,
|
||||
centerOpen,
|
||||
endOpen,
|
||||
};
|
||||
},
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nSuggestedActions,
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 50px; display: flex; justify-content: space-between; width: 800px;">
|
||||
<div>
|
||||
<h4 style="margin-bottom: 10px;">Start Alignment</h4>
|
||||
<p>{{ startOpen ? 'Open' : 'Closed' }}</p>
|
||||
<N8nSuggestedActions v-bind="args" v-model:open="startOpen" popoverAlignment="start" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin-bottom: 10px;">Center Alignment</h4>
|
||||
<p>{{ centerOpen ? 'Open' : 'Closed' }}</p>
|
||||
<N8nSuggestedActions v-bind="args" v-model:open="centerOpen" popoverAlignment="center" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin-bottom: 10px;">End Alignment</h4>
|
||||
<p>{{ endOpen ? 'Open' : 'Closed' }}</p>
|
||||
<N8nSuggestedActions v-bind="args" v-model:open="endOpen" popoverAlignment="end" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const PopoverAlignments = AlignmentTemplate.bind({});
|
||||
PopoverAlignments.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'action1',
|
||||
title: 'Test Alignment',
|
||||
description: 'This demonstrates different popover alignments.',
|
||||
},
|
||||
],
|
||||
title: 'Alignment Demo',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
export const MultipleActionsWithIgnoreAll = TemplateWithEvents.bind({});
|
||||
MultipleActionsWithIgnoreAll.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'action1',
|
||||
title: 'First action',
|
||||
description: 'This is the first action that can be ignored.',
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: 'action2',
|
||||
title: 'Second action',
|
||||
description: 'This is the second action that can be ignored.',
|
||||
},
|
||||
{
|
||||
id: 'action3',
|
||||
title: 'Third action',
|
||||
description: 'This is the third action that can be ignored.',
|
||||
},
|
||||
],
|
||||
title: 'Multiple Actions',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
const ControlledTemplate: StoryFn = (args, { argTypes }) => ({
|
||||
setup() {
|
||||
const isOpen = ref(false);
|
||||
|
||||
const toggleOpen = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const forceClose = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const onActionClick = (actionId: string) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
alert(`Action clicked: ${actionId}`);
|
||||
// Automatically close after action
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const onIgnoreClick = (actionId: string) => {
|
||||
console.log('Ignore clicked:', actionId);
|
||||
alert(`Ignore clicked: ${actionId}`);
|
||||
};
|
||||
|
||||
const onUpdateOpen = (open: boolean) => {
|
||||
console.log('External open change:', open);
|
||||
isOpen.value = open;
|
||||
};
|
||||
|
||||
return {
|
||||
args,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
forceClose,
|
||||
onActionClick,
|
||||
onIgnoreClick,
|
||||
onUpdateOpen,
|
||||
};
|
||||
},
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nSuggestedActions,
|
||||
},
|
||||
template: `
|
||||
<div style="padding: 50px;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button @click="toggleOpen" style="margin-right: 10px;">
|
||||
{{ isOpen ? 'Close' : 'Open' }} Popover
|
||||
</button>
|
||||
<button @click="forceClose">Force Close</button>
|
||||
<p style="margin-top: 10px;">Popover is: {{ isOpen ? 'Open' : 'Closed' }}</p>
|
||||
</div>
|
||||
<N8nSuggestedActions
|
||||
v-bind="args"
|
||||
v-model:open="isOpen"
|
||||
@action-click="onActionClick"
|
||||
@ignore-click="onIgnoreClick"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const ExternalControl = ControlledTemplate.bind({});
|
||||
ExternalControl.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'controlled-action',
|
||||
title: 'Externally Controlled Action',
|
||||
description: 'This popover can be controlled externally via buttons above.',
|
||||
},
|
||||
],
|
||||
title: 'External Control Demo',
|
||||
open: false,
|
||||
} satisfies SuggestedActionsProps;
|
||||
|
||||
export const WithNotice = Template.bind({});
|
||||
WithNotice.args = {
|
||||
actions: [
|
||||
{
|
||||
id: 'action-with-notice',
|
||||
title: 'Action with Notice',
|
||||
description: 'This demonstrates how the notice appears in the popover.',
|
||||
},
|
||||
{
|
||||
id: 'second-action',
|
||||
title: 'Another Action',
|
||||
description: 'This shows multiple actions with a notice displayed.',
|
||||
},
|
||||
],
|
||||
title: 'Actions with Notice',
|
||||
notice: 'Read-only environment. Update these items in development and push changes.',
|
||||
open: true,
|
||||
} satisfies SuggestedActionsProps;
|
||||
@@ -0,0 +1,336 @@
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import N8nSuggestedActions from './SuggestedActions.vue';
|
||||
|
||||
const mockActions = [
|
||||
{
|
||||
id: 'action1',
|
||||
title: 'Evaluate your workflow',
|
||||
description: 'Set up an AI evaluation to be sure th WF is reliable.',
|
||||
moreInfoLink: 'https://docs.n8n.io/evaluations',
|
||||
},
|
||||
{
|
||||
id: 'action2',
|
||||
title: 'Keep track of execution errors',
|
||||
description: 'Setup a workflow error to track what is going on here.',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock N8nPopoverReka to always render content when open
|
||||
const MockN8nPopoverReka = {
|
||||
name: 'N8nPopoverReka',
|
||||
props: ['open', 'width', 'maxHeight', 'align'],
|
||||
emits: ['update:open'],
|
||||
template: `
|
||||
<div>
|
||||
<div @click="$emit('update:open', !open)" data-test-id="popover-trigger">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
<div v-if="open" data-test-id="popover-content">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
const stubs = {
|
||||
'n8n-text': { template: '<span><slot /></span>' },
|
||||
'n8n-link': {
|
||||
props: ['to', 'href'],
|
||||
template: '<a :href="to || href"><slot /></a>',
|
||||
},
|
||||
'n8n-icon': true,
|
||||
'n8n-heading': { template: '<h4><slot /></h4>' },
|
||||
'n8n-tag': {
|
||||
props: ['text'],
|
||||
template: '<span>{{ text }}</span>',
|
||||
},
|
||||
'n8n-callout': {
|
||||
props: ['theme'],
|
||||
template: '<div data-test-id="n8n-callout" :class="theme"><slot /></div>',
|
||||
},
|
||||
N8nPopoverReka: MockN8nPopoverReka,
|
||||
};
|
||||
|
||||
describe('N8nSuggestedActions', () => {
|
||||
it('renders the suggested actions count', () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: false,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.baseElement).toContainHTML('data-test-id="suggested-action-count"');
|
||||
expect(wrapper.getByTestId('suggested-action-count')).toHaveTextContent('0 / 2');
|
||||
});
|
||||
|
||||
it('does not render the suggested actions count if all are completed', () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: [
|
||||
{ ...mockActions[0], completed: true },
|
||||
{ ...mockActions[1], completed: true },
|
||||
],
|
||||
open: false,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.baseElement).not.toContainHTML('data-test-id="suggested-action-count"');
|
||||
});
|
||||
|
||||
it('renders the suggested actions count with completed actions', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: [{ ...mockActions[0], completed: true }, mockActions[1]],
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('suggested-action-count')).toBeInTheDocument();
|
||||
expect(wrapper.getByTestId('suggested-action-count')).toHaveTextContent('1 / 2');
|
||||
|
||||
// Check if action items are visible and checked off
|
||||
const actionItems = wrapper.getAllByTestId('suggested-action-item');
|
||||
expect(actionItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows popover content when open is true', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check if action items are visible
|
||||
const actionItems = wrapper.getAllByTestId('suggested-action-item');
|
||||
expect(actionItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders all action items correctly when open', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check first action
|
||||
expect(wrapper.getByText('Evaluate your workflow')).toBeInTheDocument();
|
||||
expect(
|
||||
wrapper.getByText('Set up an AI evaluation to be sure th WF is reliable.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check second action
|
||||
expect(wrapper.getByText('Keep track of execution errors')).toBeInTheDocument();
|
||||
expect(
|
||||
wrapper.getByText('Setup a workflow error to track what is going on here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits action-click event when action button is clicked', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const actionButtons = wrapper.getAllByTestId('suggested-action-item');
|
||||
await fireEvent.click(actionButtons[0]);
|
||||
|
||||
expect(wrapper.emitted('action-click')).toBeTruthy();
|
||||
expect(wrapper.emitted('action-click')[0]).toEqual(['action1']);
|
||||
});
|
||||
|
||||
it('emits ignore-click event when ignore link is clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const ignoreLinks = wrapper.getAllByTestId('suggested-action-ignore');
|
||||
await fireEvent.click(ignoreLinks[0]);
|
||||
|
||||
// Advance timers to trigger the delayed emission
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('ignore-click')).toBeTruthy();
|
||||
expect(wrapper.emitted('ignore-click')[0]).toEqual(['action1']);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('emits update:open event when popover state changes', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: false,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const countTag = wrapper.getByTestId('suggested-action-count');
|
||||
await fireEvent.click(countTag);
|
||||
|
||||
// Check that update:open event was emitted
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy();
|
||||
expect(wrapper.emitted('update:open')[0]).toEqual([true]);
|
||||
});
|
||||
|
||||
it('shows custom ignore all text when ignoreAllLabel is provided', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
ignoreAllLabel: 'Ignore for all',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('suggested-action-ignore-all')).toBeInTheDocument();
|
||||
expect(wrapper.getByText('Ignore for all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits ignore-all event when turn off link is clicked', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
ignoreAllLabel: 'Ignore for all',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const turnOffLink = wrapper.getByTestId('suggested-action-ignore-all');
|
||||
expect(wrapper.getByText('Ignore for all')).toBeInTheDocument();
|
||||
await fireEvent.click(turnOffLink);
|
||||
|
||||
expect(wrapper.emitted('ignore-all')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders more info link when moreInfoLink is provided', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const moreInfoLinks = wrapper.getAllByText('More info');
|
||||
expect(moreInfoLinks).toHaveLength(1); // Only first action has moreInfoLink
|
||||
|
||||
const link = moreInfoLinks[0].closest('a') as HTMLAnchorElement;
|
||||
expect(link.getAttribute('href')).toBe('https://docs.n8n.io/evaluations');
|
||||
});
|
||||
|
||||
it('applies ignoring class when action is being ignored', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const actionItems = wrapper.getAllByTestId('suggested-action-item');
|
||||
const ignoreLinks = wrapper.getAllByTestId('suggested-action-ignore');
|
||||
|
||||
// Click ignore on first action
|
||||
await fireEvent.click(ignoreLinks[0]);
|
||||
|
||||
// Check if the action item has the ignoring class
|
||||
expect(actionItems[0]).toHaveClass('ignoring');
|
||||
});
|
||||
|
||||
it('respects popoverAlignment prop', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: false,
|
||||
title: 'Test Title',
|
||||
popoverAlignment: 'start',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check that the component renders correctly with the alignment prop
|
||||
expect(wrapper.getByTestId('suggested-action-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Notice functionality', () => {
|
||||
it('renders notice alert when notice prop is provided', async () => {
|
||||
const noticeText = 'This is a notice message';
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
notice: noticeText,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check that the notice callout is rendered
|
||||
const callout = wrapper.getByTestId('n8n-callout');
|
||||
expect(callout).toBeInTheDocument();
|
||||
expect(callout).toHaveClass('warning');
|
||||
expect(callout).toHaveTextContent(noticeText);
|
||||
});
|
||||
|
||||
it('does not render notice callout when notice prop is not provided', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check that the notice callout is not rendered
|
||||
expect(wrapper.queryByTestId('n8n-callout')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render notice callout when notice prop is empty string', async () => {
|
||||
const wrapper = render(N8nSuggestedActions, {
|
||||
props: {
|
||||
actions: mockActions,
|
||||
open: true,
|
||||
title: 'Test Title',
|
||||
notice: '',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check that the notice callout is not rendered
|
||||
expect(wrapper.queryByTestId('n8n-callout')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import N8nCallout from '../N8nCallout';
|
||||
import N8nHeading from '../N8nHeading';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import N8nLink from '../N8nLink';
|
||||
import N8nPopoverReka from '../N8nPopoverReka';
|
||||
import N8nTag from '../N8nTag';
|
||||
import N8nText from '../N8nText';
|
||||
|
||||
interface SuggestedAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
moreInfoLink?: string;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
export interface SuggestedActionsProps {
|
||||
title: string;
|
||||
actions: SuggestedAction[];
|
||||
open: boolean;
|
||||
ignoreAllLabel?: string;
|
||||
popoverAlignment?: 'start' | 'end' | 'center';
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
interface SuggestedActionsEmits {
|
||||
(event: 'action-click', actionId: string): void;
|
||||
(event: 'ignore-click', actionId: string): void;
|
||||
(event: 'ignore-all'): void;
|
||||
(event: 'update:open', open: boolean): void;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'N8nSuggestedActions' });
|
||||
|
||||
const props = withDefaults(defineProps<SuggestedActionsProps>(), {
|
||||
ignoreAllLabel: undefined,
|
||||
popoverAlignment: undefined,
|
||||
notice: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<SuggestedActionsEmits>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const ignoringActions = ref<Set<string>>(new Set());
|
||||
|
||||
const completedCount = computed(() => props.actions.filter((action) => action.completed).length);
|
||||
|
||||
const handleActionClick = (action: SuggestedAction) => {
|
||||
if (!action.completed) {
|
||||
emit('action-click', action.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnoreClick = (actionId: string) => {
|
||||
ignoringActions.value.add(actionId);
|
||||
setTimeout(() => {
|
||||
emit('ignore-click', actionId);
|
||||
ignoringActions.value.delete(actionId);
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nPopoverReka
|
||||
v-if="completedCount !== actions.length"
|
||||
:open="open"
|
||||
width="360px"
|
||||
max-height="500px"
|
||||
:align="popoverAlignment"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<template #trigger>
|
||||
<div
|
||||
:class="[$style.triggerContainer, open ? $style.activeTrigger : '']"
|
||||
data-test-id="suggested-action-count"
|
||||
>
|
||||
<N8nTag :text="`${completedCount} / ${actions.length}`" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.popoverContent">
|
||||
<N8nHeading tag="h4">{{ title }}</N8nHeading>
|
||||
<N8nCallout v-if="notice" theme="warning">{{ notice }}</N8nCallout>
|
||||
<div
|
||||
v-for="action in actions"
|
||||
:key="action.id"
|
||||
:class="[
|
||||
{
|
||||
[$style.actionItem]: true,
|
||||
[$style.ignoring]: ignoringActions.has(action.id),
|
||||
[$style.actionable]: !action.completed,
|
||||
},
|
||||
]"
|
||||
data-test-id="suggested-action-item"
|
||||
:data-action-id="action.id"
|
||||
@click.prevent.stop="() => handleActionClick(action)"
|
||||
>
|
||||
<div :class="$style.checkboxContainer">
|
||||
<N8nIcon v-if="action.completed" icon="circle-check" color="success" />
|
||||
<N8nIcon v-else icon="circle" color="foreground-dark" />
|
||||
</div>
|
||||
<div :class="$style.actionItemBody">
|
||||
<div :class="[action.completed ? '' : 'mb-3xs', $style.actionHeader]">
|
||||
<N8nText size="medium" :bold="true">{{ action.title }}</N8nText>
|
||||
</div>
|
||||
<div v-if="!action.completed">
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ action.description }}
|
||||
<N8nLink
|
||||
v-if="action.moreInfoLink"
|
||||
:to="action.moreInfoLink"
|
||||
size="small"
|
||||
theme="text"
|
||||
new-window
|
||||
underline
|
||||
@click.stop
|
||||
>
|
||||
{{ t('generic.moreInfo') }}
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<N8nLink
|
||||
theme="text"
|
||||
:title="t('generic.ignore')"
|
||||
data-test-id="suggested-action-ignore"
|
||||
@click.prevent.stop="handleIgnoreClick(action.id)"
|
||||
>
|
||||
<N8nIcon v-if="!action.completed" icon="x" size="large" />
|
||||
</N8nLink>
|
||||
</div>
|
||||
<div :class="$style.ignoreAllContainer">
|
||||
<N8nLink
|
||||
theme="text"
|
||||
size="small"
|
||||
underline
|
||||
data-test-id="suggested-action-ignore-all"
|
||||
@click.prevent.stop="emit('ignore-all')"
|
||||
>
|
||||
{{ ignoreAllLabel ?? t('generic.ignoreAll') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.triggerContainer {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
--tag-height: 24px;
|
||||
}
|
||||
|
||||
.activeTrigger {
|
||||
--tag-text-color: var(--color-primary);
|
||||
--tag-border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popoverContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m) var(--spacing-s);
|
||||
}
|
||||
|
||||
.actionItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
filter 0.3s ease;
|
||||
border-bottom: var(--border-base);
|
||||
|
||||
&.ignoring {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.8);
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.actionable {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.actionHeader {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:has(a:hover) {
|
||||
.actionHeader {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionItemBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
padding-top: 1px;
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.ignoreAllContainer {
|
||||
padding-left: var(--spacing-5xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SuggestedActions.vue';
|
||||
@@ -142,84 +142,86 @@ const handleDragEnd = () => {
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="visibleColumns.length"
|
||||
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||
data-testid="visible-columns-section"
|
||||
>
|
||||
<h5 :class="$style.header">
|
||||
{{ t('tableControlsButton.shown') }}
|
||||
</h5>
|
||||
<div v-for="column in visibleColumns" :key="column.key" :class="$style.columnWrapper">
|
||||
<div :class="$style.contentContainer">
|
||||
<div
|
||||
v-if="visibleColumns.length"
|
||||
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||
data-testid="visible-columns-section"
|
||||
>
|
||||
<h5 :class="$style.header">
|
||||
{{ t('tableControlsButton.shown') }}
|
||||
</h5>
|
||||
<div v-for="column in visibleColumns" :key="column.key" :class="$style.columnWrapper">
|
||||
<div
|
||||
v-if="dragOverItem === column.key"
|
||||
:class="$style.dropIndicator"
|
||||
data-testid="drop-indicator"
|
||||
></div>
|
||||
<fieldset
|
||||
:class="[
|
||||
$style.column,
|
||||
$style.draggable,
|
||||
{ [$style.dragging]: draggedItem === column.key },
|
||||
]"
|
||||
draggable="true"
|
||||
data-testid="visible-column"
|
||||
:data-column-key="column.key"
|
||||
@dragstart="(event) => handleDragStart(event, column.key)"
|
||||
@dragover="(event) => handleDragOver(event, column.key)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="(event) => handleDrop(event, column.key)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<N8nIcon icon="grip-vertical" :class="$style.grip" />
|
||||
<label>{{ column.label }}</label>
|
||||
<N8nIcon
|
||||
:class="$style.visibilityToggle"
|
||||
icon="eye"
|
||||
data-testid="visibility-toggle-visible"
|
||||
@click="() => emit('update:columnVisibility', column.key, false)"
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<!-- Drop zone at the end -->
|
||||
<div
|
||||
v-if="dragOverItem === column.key"
|
||||
:class="$style.dropIndicator"
|
||||
data-testid="drop-indicator"
|
||||
></div>
|
||||
<fieldset
|
||||
:class="[
|
||||
$style.column,
|
||||
$style.draggable,
|
||||
{ [$style.dragging]: draggedItem === column.key },
|
||||
]"
|
||||
draggable="true"
|
||||
data-testid="visible-column"
|
||||
:data-column-key="column.key"
|
||||
@dragstart="(event) => handleDragStart(event, column.key)"
|
||||
@dragover="(event) => handleDragOver(event, column.key)"
|
||||
:class="$style.endDropZone"
|
||||
data-testid="end-drop-zone"
|
||||
@dragover="(event) => handleDragOver(event, 'END')"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="(event) => handleDrop(event, column.key)"
|
||||
@dragend="handleDragEnd"
|
||||
@drop="(event) => handleDrop(event, 'END')"
|
||||
>
|
||||
<N8nIcon icon="grip-vertical" :class="$style.grip" />
|
||||
<div
|
||||
v-if="dragOverItem === 'END'"
|
||||
:class="$style.dropIndicator"
|
||||
data-testid="drop-indicator"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hiddenColumns.length"
|
||||
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||
data-testid="hidden-columns-section"
|
||||
>
|
||||
<h4 :class="$style.header">
|
||||
{{ t('tableControlsButton.hidden') }}
|
||||
</h4>
|
||||
<fieldset
|
||||
v-for="column in hiddenColumns"
|
||||
:key="column.key"
|
||||
:class="[$style.column, $style.hidden]"
|
||||
data-testid="hidden-column"
|
||||
:data-column-key="column.key"
|
||||
>
|
||||
<N8nIcon icon="grip-vertical" :class="[$style.grip, $style.hidden]" />
|
||||
<label>{{ column.label }}</label>
|
||||
<N8nIcon
|
||||
:class="$style.visibilityToggle"
|
||||
icon="eye"
|
||||
data-testid="visibility-toggle-visible"
|
||||
@click="() => emit('update:columnVisibility', column.key, false)"
|
||||
icon="eye-off"
|
||||
data-testid="visibility-toggle-hidden"
|
||||
@click="() => emit('update:columnVisibility', column.key, true)"
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<!-- Drop zone at the end -->
|
||||
<div
|
||||
:class="$style.endDropZone"
|
||||
data-testid="end-drop-zone"
|
||||
@dragover="(event) => handleDragOver(event, 'END')"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="(event) => handleDrop(event, 'END')"
|
||||
>
|
||||
<div
|
||||
v-if="dragOverItem === 'END'"
|
||||
:class="$style.dropIndicator"
|
||||
data-testid="drop-indicator"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hiddenColumns.length"
|
||||
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||
data-testid="hidden-columns-section"
|
||||
>
|
||||
<h4 :class="$style.header">
|
||||
{{ t('tableControlsButton.hidden') }}
|
||||
</h4>
|
||||
<fieldset
|
||||
v-for="column in hiddenColumns"
|
||||
:key="column.key"
|
||||
:class="[$style.column, $style.hidden]"
|
||||
data-testid="hidden-column"
|
||||
:data-column-key="column.key"
|
||||
>
|
||||
<N8nIcon icon="grip-vertical" :class="[$style.grip, $style.hidden]" />
|
||||
<label>{{ column.label }}</label>
|
||||
<N8nIcon
|
||||
:class="$style.visibilityToggle"
|
||||
icon="eye-off"
|
||||
data-testid="visibility-toggle-hidden"
|
||||
@click="() => emit('update:columnVisibility', column.key, true)"
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
@@ -241,6 +243,10 @@ const handleDragEnd = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -5,7 +5,12 @@ exports[`TableHeaderControlsButton > Disabled columns > should render correctly
|
||||
<trigger>
|
||||
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||
</trigger>
|
||||
<content></content>
|
||||
<content>
|
||||
<div class="contentContainer">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</content>
|
||||
</div>"
|
||||
`;
|
||||
|
||||
@@ -15,29 +20,31 @@ exports[`TableHeaderControlsButton > should render correctly with all columns hi
|
||||
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||
</trigger>
|
||||
<content>
|
||||
<!--v-if-->
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||
<h4 class="header">Hidden columns</h4>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<div class="contentContainer">
|
||||
<!--v-if-->
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||
<h4 class="header">Hidden columns</h4>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</content>
|
||||
</div>"
|
||||
@@ -49,48 +56,50 @@ exports[`TableHeaderControlsButton > should render correctly with all columns vi
|
||||
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||
</trigger>
|
||||
<content>
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||
<h5 class="header">Shown columns</h5>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div><!-- Drop zone at the end -->
|
||||
<div class="endDropZone" data-testid="end-drop-zone">
|
||||
<!--v-if-->
|
||||
<div class="contentContainer">
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||
<h5 class="header">Shown columns</h5>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div><!-- Drop zone at the end -->
|
||||
<div class="endDropZone" data-testid="end-drop-zone">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</content>
|
||||
</div>"
|
||||
`;
|
||||
@@ -101,43 +110,45 @@ exports[`TableHeaderControlsButton > should render correctly with mixed visible
|
||||
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||
</trigger>
|
||||
<content>
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||
<h5 class="header">Shown columns</h5>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
<div class="contentContainer">
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||
<h5 class="header">Shown columns</h5>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div><!-- Drop zone at the end -->
|
||||
<div class="endDropZone" data-testid="end-drop-zone">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||
<h4 class="header">Hidden columns</h4>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="columnWrapper">
|
||||
<!--v-if-->
|
||||
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div><!-- Drop zone at the end -->
|
||||
<div class="endDropZone" data-testid="end-drop-zone">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||
<h4 class="header">Hidden columns</h4>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||
</fieldset>
|
||||
</div>
|
||||
</content>
|
||||
</div>"
|
||||
@@ -148,6 +159,11 @@ exports[`TableHeaderControlsButton > should render correctly with no columns 1`]
|
||||
<trigger>
|
||||
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||
</trigger>
|
||||
<content></content>
|
||||
<content>
|
||||
<div class="contentContainer">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</content>
|
||||
</div>"
|
||||
`;
|
||||
|
||||
@@ -36,6 +36,7 @@ export { default as N8nNotice } from './N8nNotice';
|
||||
export { default as N8nOption } from './N8nOption';
|
||||
export { default as N8nSelectableList } from './N8nSelectableList';
|
||||
export { default as N8nPopover } from './N8nPopover';
|
||||
export { default as N8nPopoverReka } from './N8nPopoverReka';
|
||||
export { default as N8nPulse } from './N8nPulse';
|
||||
export { default as N8nRadioButtons } from './N8nRadioButtons';
|
||||
export { default as N8nRoute } from './N8nRoute';
|
||||
@@ -45,6 +46,7 @@ export { default as N8nSelect } from './N8nSelect';
|
||||
export { default as N8nSpinner } from './N8nSpinner';
|
||||
export { default as N8nSticky } from './N8nSticky';
|
||||
export { default as N8nResizeableSticky } from './N8nResizeableSticky';
|
||||
export { default as N8nSuggestedActions } from './N8nSuggestedActions';
|
||||
export { default as N8nTabs } from './N8nTabs';
|
||||
export { default as N8nTag } from './N8nTag';
|
||||
export { default as N8nTags } from './N8nTags';
|
||||
|
||||
@@ -206,6 +206,18 @@
|
||||
--color-button-secondary-disabled-font: var(--prim-gray-0-alpha-030);
|
||||
--color-button-secondary-disabled-border: var(--prim-gray-0-alpha-030);
|
||||
|
||||
// Button highlight
|
||||
--color-button-highlight-font: var(--prim-gray-320);
|
||||
--color-button-highlight-border: transparent;
|
||||
--color-button-highlight-background: transparent;
|
||||
--color-button-highlight-hover-active-focus-font: var(--prim-color-primary-tint-100);
|
||||
--color-button-highlight-hover-active-focus-border: var(--prim-gray-670);
|
||||
--color-button-highlight-hover-background: var(--prim-gray-670);
|
||||
--color-button-highlight-active-focus-background: var(--prim-gray-670);
|
||||
--color-button-highlight-focus-outline: var(--prim-gray-670);
|
||||
--color-button-highlight-disabled-font: var(--prim-gray-0-alpha-010);
|
||||
--color-button-highlight-disabled-border: transparent;
|
||||
|
||||
// Button success, warning, danger
|
||||
--color-button-danger-font: var(--prim-gray-0);
|
||||
--color-button-danger-border: transparent;
|
||||
|
||||
@@ -264,6 +264,19 @@
|
||||
--color-button-secondary-disabled-font: var(--prim-gray-200);
|
||||
--color-button-secondary-disabled-border: var(--prim-gray-200);
|
||||
|
||||
// Button highlight
|
||||
--color-button-highlight-font: var(--prim-gray-670);
|
||||
--color-button-highlight-border: transparent;
|
||||
--color-button-highlight-background: transparent;
|
||||
--color-button-highlight-hover-active-focus-font: var(--prim-color-primary-shade-100);
|
||||
--color-button-highlight-hover-active-focus-border: var(--prim-gray-40);
|
||||
--color-button-highlight-hover-background: var(--prim-gray-40);
|
||||
--color-button-highlight-active-focus-background: var(--prim-gray-40);
|
||||
--color-button-highlight-focus-outline: var(--prim-gray-40);
|
||||
--color-button-highlight-disabled-font: var(--prim-gray-120);
|
||||
--color-button-highlight-disabled-border: transparent;
|
||||
--color-button-highlight-disabled-background: transparent;
|
||||
|
||||
// Button success, warning, danger
|
||||
--color-button-success-font: var(--prim-gray-0);
|
||||
--color-button-success-disabled-font: var(--prim-gray-0-alpha-075);
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { N8nLocale } from '@n8n/design-system/types';
|
||||
export default {
|
||||
'generic.retry': 'Retry',
|
||||
'generic.cancel': 'Cancel',
|
||||
'generic.ignore': 'Ignore',
|
||||
'generic.ignoreAll': 'Ignore all',
|
||||
'generic.moreInfo': 'More info',
|
||||
'nds.auth.roles.owner': 'Owner',
|
||||
'nds.userInfo.you': '(you)',
|
||||
'nds.userSelect.selectUser': 'Select User',
|
||||
|
||||
@@ -5,7 +5,15 @@ import type { IconName } from '../components/N8nIcon/icons';
|
||||
const BUTTON_ELEMENT = ['button', 'a'] as const;
|
||||
export type ButtonElement = (typeof BUTTON_ELEMENT)[number];
|
||||
|
||||
const BUTTON_TYPE = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'] as const;
|
||||
const BUTTON_TYPE = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'tertiary',
|
||||
'success',
|
||||
'warning',
|
||||
'danger',
|
||||
'highlight',
|
||||
] as const;
|
||||
export type ButtonType = (typeof BUTTON_TYPE)[number];
|
||||
|
||||
const BUTTON_SIZE = ['xmini', 'mini', 'small', 'medium', 'large'] as const;
|
||||
|
||||
@@ -2493,6 +2493,18 @@
|
||||
"workflowDetails.active": "Active",
|
||||
"workflowDetails.addTag": "Add tag",
|
||||
"workflowDetails.chooseOrCreateATag": "Choose or create a tag",
|
||||
"workflowProductionChecklist.title": "Production Checklist",
|
||||
"workflowProductionChecklist.readOnlyNotice": "Read-only environment. Update these items in development and push changes.",
|
||||
"workflowProductionChecklist.ignoreAllConfirmation.title": "Ignore suggested actions globally",
|
||||
"workflowProductionChecklist.ignoreAllConfirmation.description": "Turning this off dismisses these suggestions from every workflow",
|
||||
"workflowProductionChecklist.ignoreAllConfirmation.confirm": "Ignore for all workflows",
|
||||
"workflowProductionChecklist.turnOffWorkflowSuggestions": "Ignore for all workflows",
|
||||
"workflowProductionChecklist.evaluations.title": "Test reliability of AI steps",
|
||||
"workflowProductionChecklist.evaluations.description": "Use evaluations to check performance on a range of inputs.",
|
||||
"workflowProductionChecklist.errorWorkflow.title": "Set up error notifications",
|
||||
"workflowProductionChecklist.errorWorkflow.description": "Customize exactly how you’re notified by setting up an error workflow",
|
||||
"workflowProductionChecklist.timeSaved.title": "Track time saved",
|
||||
"workflowProductionChecklist.timeSaved.description": "Configure the time saved on each run to track the manual work it saves.",
|
||||
"workflowExtraction.error.failure": "Sub-workflow conversion failed",
|
||||
"workflowExtraction.error.selectionGraph.inputEdgeToNonRoot": "Non-input node '{node}' has a connection from a node outside the current selection.",
|
||||
"workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf": "Non-output node '{node}' has a connection to a node outside the current selection.",
|
||||
@@ -2547,7 +2559,7 @@
|
||||
"workflowSettings.callerPolicy.options.none": "No other workflows",
|
||||
"workflowSettings.defaultTimezone": "Default - {defaultTimezoneValue}",
|
||||
"workflowSettings.defaultTimezoneNotValid": "Default Timezone not valid",
|
||||
"workflowSettings.errorWorkflow": "Error Workflow",
|
||||
"workflowSettings.errorWorkflow": "Error Workflow (to notify when this one errors)",
|
||||
"workflowSettings.executionOrder": "Execution Order",
|
||||
"workflowSettings.helpTexts.errorWorkflow": "A second workflow to run if the current one fails.<br />The second workflow should have an 'Error Trigger' node.",
|
||||
"workflowSettings.helpTexts.executionTimeout": "How long the workflow should wait before timing out",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/frontend/editor-ui/src/composables/useWorkflowsCache.ts
Normal file
139
packages/frontend/editor-ui/src/composables/useWorkflowsCache.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Playwright E2E Test Guide
|
||||
|
||||
## Development setup
|
||||
```bash
|
||||
pnpm install-browsers:local # in playwright directory
|
||||
pnpm build:docker # from root first to test against local changes
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
pnpm test:all # Run all tests (fresh containers, pnpm build:local from root first to ensure local containers)
|
||||
pnpm test:all # Run all tests (fresh containers, pnpm build:docker from root first to ensure local containers)
|
||||
pnpm test:local # Starts a local server and runs the UI tests
|
||||
N8N_BASE_URL=localhost:5068 pnpm test:local # Runs the UI tests against the instance running
|
||||
```
|
||||
@@ -21,6 +27,7 @@ pnpm test:chaos # Runs the chaos tests
|
||||
|
||||
# Development
|
||||
pnpm test:all --grep "workflow" # Pattern match, can run across all test types UI/cli-workflow/performance
|
||||
pnpm test:local --ui # To enable UI debugging and test running mode
|
||||
```
|
||||
|
||||
## Test Tags
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const getSuggestedActionsButton = (page: Page) => page.getByTestId('suggested-action-count');
|
||||
export const getSuggestedActionItem = (page: Page, text?: string) => {
|
||||
const items = page.getByTestId('suggested-action-item');
|
||||
if (text) {
|
||||
return items.getByText(text);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
export const getSuggestedActionsPopover = (page: Page) =>
|
||||
page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
|
||||
|
||||
export const getErrorActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Set up error notifications');
|
||||
|
||||
export const getTimeSavedActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Track time saved');
|
||||
|
||||
export const getEvaluationsActionItem = (page: Page) =>
|
||||
getSuggestedActionItem(page, 'Test reliability of AI steps');
|
||||
|
||||
export const getIgnoreAllButton = (page: Page) => page.getByTestId('suggested-action-ignore-all');
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const getActivationModal = (page: Page) => page.getByTestId('activation-modal');
|
||||
|
||||
export const closeActivationModal = async (page: Page) => {
|
||||
await expect(getActivationModal(page)).toBeVisible();
|
||||
|
||||
// click checkbox so it does not show again
|
||||
await getActivationModal(page).getByText("Don't show again").click();
|
||||
|
||||
// confirm modal
|
||||
await getActivationModal(page).getByRole('button', { name: 'Got it' }).click();
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Helper to open workflow settings modal
|
||||
export const openWorkflowSettings = async (page: Page) => {
|
||||
await page.getByTestId('workflow-menu').click();
|
||||
await page.getByTestId('workflow-menu-item-settings').click();
|
||||
await expect(page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
};
|
||||
@@ -32,6 +32,10 @@ export class CanvasPage extends BasePage {
|
||||
return this.nodeToolbar(nodeName).getByTestId('delete-node-button');
|
||||
}
|
||||
|
||||
nodeDisableButton(nodeName: string): Locator {
|
||||
return this.nodeToolbar(nodeName).getByTestId('disable-node-button');
|
||||
}
|
||||
|
||||
async clickCanvasPlusButton(): Promise<void> {
|
||||
await this.clickByTestId('canvas-plus-button');
|
||||
}
|
||||
@@ -58,6 +62,15 @@ export class CanvasPage extends BasePage {
|
||||
await this.clickNodeCreatorItemName(text);
|
||||
}
|
||||
|
||||
async addNodeAndCloseNDV(text: string, subItemText?: string): Promise<void> {
|
||||
if (subItemText) {
|
||||
await this.addNodeToCanvasWithSubItem(text, subItemText);
|
||||
} else {
|
||||
await this.addNode(text);
|
||||
}
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
async addNodeToCanvasWithSubItem(searchText: string, subItemText: string): Promise<void> {
|
||||
await this.addNode(searchText);
|
||||
await this.nodeCreatorSubItem(subItemText).click();
|
||||
@@ -148,4 +161,13 @@ export class CanvasPage extends BasePage {
|
||||
getWorkflowTags() {
|
||||
return this.page.getByTestId('workflow-tags').locator('.el-tag');
|
||||
}
|
||||
|
||||
async activateWorkflow() {
|
||||
const responsePromise = this.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/rest/workflows/') && response.request().method() === 'PATCH',
|
||||
);
|
||||
await this.page.getByTestId('workflow-activate-switch').click();
|
||||
await responsePromise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
getErrorActionItem,
|
||||
getEvaluationsActionItem,
|
||||
getIgnoreAllButton,
|
||||
getSuggestedActionItem,
|
||||
getSuggestedActionsButton,
|
||||
getSuggestedActionsPopover,
|
||||
getTimeSavedActionItem,
|
||||
} from '../../composables/ProductionChecklist';
|
||||
import { closeActivationModal } from '../../composables/WorkflowActivationModal';
|
||||
import { openWorkflowSettings } from '../../composables/WorkflowSettingsModal';
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
|
||||
|
||||
test.describe('Workflow Production Checklist', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.clickAddWorkflowButton();
|
||||
});
|
||||
|
||||
test('should show suggested actions automatically when workflow is first activated', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Add a schedule trigger node (activatable)
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
|
||||
// Verify suggested actions button is not visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
|
||||
|
||||
// Activate the workflow
|
||||
await n8n.canvas.activateWorkflow();
|
||||
|
||||
// Activation Modal should be visible since it's first activation
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
// Verify suggested actions button and popover is visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(2);
|
||||
await expect(getErrorActionItem(n8n.page)).toBeVisible();
|
||||
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display evaluations action when AI node exists and feature is enabled', async ({
|
||||
n8n,
|
||||
api,
|
||||
}) => {
|
||||
// Enable evaluations feature
|
||||
await api.enableFeature('evaluation');
|
||||
|
||||
// Add schedule trigger and AI node
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNodeAndCloseNDV('OpenAI', 'Create an assistant');
|
||||
|
||||
await n8n.canvas.nodeDisableButton('Create an assistant').click();
|
||||
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
// Suggested actions should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
await expect(getSuggestedActionItem(n8n.page)).toHaveCount(3);
|
||||
|
||||
// Verify evaluations action is present
|
||||
await expect(getEvaluationsActionItem(n8n.page)).toBeVisible();
|
||||
await getEvaluationsActionItem(n8n.page).click();
|
||||
|
||||
// Verify navigation to evaluations page
|
||||
await expect(n8n.page).toHaveURL(/\/evaluation/);
|
||||
});
|
||||
|
||||
test('should open workflow settings modal when error workflow action is clicked', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
|
||||
// Click error workflow action
|
||||
const errorAction = getErrorActionItem(n8n.page);
|
||||
await expect(errorAction).toBeVisible();
|
||||
await errorAction.click();
|
||||
|
||||
// Verify workflow settings modal opens
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
await expect(n8n.page.getByTestId('workflow-settings-error-workflow')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open workflow settings modal when time saved action is clicked', async ({ n8n }) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
|
||||
// Click time saved action
|
||||
const timeAction = getTimeSavedActionItem(n8n.page);
|
||||
await expect(timeAction).toBeVisible();
|
||||
await timeAction.click();
|
||||
|
||||
// Verify workflow settings modal opens
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow ignoring individual actions', async ({ n8n }) => {
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
// Suggested actions popover should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
|
||||
// Verify error workflow action is visible
|
||||
await expect(getSuggestedActionItem(n8n.page).first()).toContainText('error');
|
||||
await getSuggestedActionItem(n8n.page).first().getByTitle('Ignore').click();
|
||||
await n8n.page.waitForTimeout(500); // items disappear after timeout, not arbitrary
|
||||
await expect(getErrorActionItem(n8n.page)).toBeHidden();
|
||||
|
||||
// Close and reopen popover
|
||||
await n8n.page.locator('body').click({ position: { x: 0, y: 0 } });
|
||||
await getSuggestedActionsButton(n8n.page).click();
|
||||
|
||||
// Verify error workflow action is still no longer visible
|
||||
await expect(getErrorActionItem(n8n.page)).toBeHidden();
|
||||
await expect(getTimeSavedActionItem(n8n.page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show completed state for configured actions', async ({ n8n }) => {
|
||||
// Add schedule trigger and activate workflow
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
// Open workflow settings and set error workflow
|
||||
await openWorkflowSettings(n8n.page);
|
||||
|
||||
// Set an error workflow (we'll use a dummy value)
|
||||
await n8n.page.getByTestId('workflow-settings-error-workflow').click();
|
||||
await n8n.page.getByRole('option', { name: 'My workflow' }).first().click();
|
||||
await n8n.page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeHidden();
|
||||
|
||||
// Open suggested actions
|
||||
await getSuggestedActionsButton(n8n.page).click();
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
|
||||
// Verify error workflow action shows as completed
|
||||
await expect(
|
||||
getSuggestedActionItem(n8n.page).first().locator('svg[data-icon="circle-check"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow ignoring all actions with confirmation', async ({ n8n }) => {
|
||||
// Add schedule trigger
|
||||
await n8n.canvas.addNodeAndCloseNDV(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.saveWorkflow();
|
||||
await n8n.canvas.activateWorkflow();
|
||||
await closeActivationModal(n8n.page);
|
||||
|
||||
// Suggested actions should be open
|
||||
await expect(getSuggestedActionsPopover(n8n.page)).toBeVisible();
|
||||
|
||||
// Click ignore all button
|
||||
await getIgnoreAllButton(n8n.page).click();
|
||||
|
||||
// Confirm in the dialog
|
||||
await expect(n8n.page.locator('.el-message-box')).toBeVisible();
|
||||
await n8n.page
|
||||
.locator('.el-message-box__btns button')
|
||||
.filter({ hasText: /ignore for all workflows/i })
|
||||
.click();
|
||||
|
||||
// Verify suggested actions button is no longer visible
|
||||
await expect(getSuggestedActionsButton(n8n.page)).toBeHidden();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user