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

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

View File

@@ -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);

View File

@@ -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',

View File

@@ -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();
});
});
});
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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>"`;

View File

@@ -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'] {

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { default } from './SuggestedActions.vue';

View File

@@ -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;

View File

@@ -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>"
`;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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);

View File

@@ -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',

View File

@@ -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;