feat(editor): Add instance settings components and Storybook examples (#32821)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
Jan
2026-07-09 22:59:48 +02:00
committed by GitHub
parent 2a96b76a63
commit b5fe23bedd
34 changed files with 5690 additions and 0 deletions
@@ -0,0 +1,170 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import type { Component } from 'vue';
import N8nSettingsLayout from './SettingsLayout.vue';
import N8nDataTableServer from '../N8nDataTableServer';
import N8nSettingsPageHeader from '../N8nSettingsPageHeader';
import N8nSettingsRow from '../N8nSettingsRow';
import N8nSettingsRowGroup from '../N8nSettingsRowGroup';
import N8nSettingsSection from '../N8nSettingsSection';
import N8nSwitch from '../N8nSwitch';
import N8nText from '../N8nText';
const meta = {
title: 'Instance Settings/Settings Layout',
component: N8nSettingsLayout,
argTypes: {
showBack: { control: 'boolean' },
backLabel: { control: 'text' },
fullWidth: { control: 'boolean' },
},
parameters: {
docs: {
description: {
component:
'Pads the settings page (24px sides/top, 48px bottom), centers the content and caps it at the content max-width (`--settings-content--max-width`, 45rem / 720px), and optionally renders the ghost back action pinned to the top-left. Set `fullWidth` to let wide content (e.g. a table) span the padded container; the page header always stays centered at 720px so its position is consistent across pages. The page-header → content gap is fixed at 48px (`--spacing--2xl`), enforced here via `.content > header + *` (owned by the following element so it never depends on margin-collapsing) and not configurable; other direct children fall back to a 24px (`--spacing--lg`) rhythm.',
},
},
},
} satisfies Meta<typeof N8nSettingsLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
const page = `
<N8nSettingsPageHeader
title="Security & login"
description="Your 2FA setup, passkeys, active sessions, and authorized OAuth applications."
docs-url="https://docs.n8n.io/user-management/"
/>
<N8nSettingsSection title="Sign-in" description="How you sign in to n8n.">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Telemetry" description="Share anonymous usage data.">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Beta features" description="Opt in to early features.">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
`;
const renderPage: Story['render'] = (args) => ({
components: {
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsSection,
N8nSettingsRowGroup,
N8nSettingsRow,
N8nSwitch,
},
setup() {
const enabled = ref(true);
const onBack = () => alert('back');
return { args, enabled, onBack };
},
template: `<N8nSettingsLayout v-bind="args" @back="onBack">${page}</N8nSettingsLayout>`,
});
export const Default: Story = {
render: renderPage,
args: {
showBack: false,
},
};
export const WithBackButton: Story = {
render: renderPage,
args: {
showBack: true,
backLabel: 'Back to app',
},
};
export const NestedBackLabel: Story = {
render: renderPage,
args: {
showBack: true,
backLabel: 'Back to Security settings',
},
parameters: {
docs: {
description: {
story:
'On a nested sub-page, set `back-label` to express where back goes (e.g. "Back to Security settings"). The label is also the back buttons accessible name.',
},
},
},
};
const tablePage = `
<N8nSettingsPageHeader
title="API keys"
description="Use your API keys to control n8n programmatically."
docs-url="https://docs.n8n.io/api/"
/>
<N8nSettingsSection>
<N8nDataTableServer :headers="headers" :items="items" :items-length="items.length">
<template #[slotApiKey]="{ value }">
<N8nText size="small" color="text-base">{{ value }}</N8nText>
</template>
</N8nDataTableServer>
</N8nSettingsSection>
`;
export const FullWidthTable: Story = {
render: (args) => ({
components: {
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsSection,
N8nDataTableServer: N8nDataTableServer as unknown as Component,
N8nText,
},
setup() {
const headers = [
{ title: 'Label', key: 'label' },
{ title: 'API key', key: 'apiKey', disableSort: true },
{ title: 'Created', key: 'created' },
{ title: 'Last used', key: 'lastUsed' },
];
const items = [
{
id: '1',
label: 'Production',
apiKey: '••••••••••••3f9a',
created: '7 months ago',
lastUsed: '3 min ago',
},
{
id: '2',
label: 'CI / CD',
apiKey: '••••••••••••a17c',
created: '7 days ago',
lastUsed: '14 min ago',
},
{
id: '3',
label: 'Staging',
apiKey: '••••••••••••4b2e',
created: '17 hours ago',
lastUsed: 'Yesterday',
},
{
id: '4',
label: 'Local dev',
apiKey: '••••••••••••9d05',
created: '3 months ago',
lastUsed: 'Last week',
},
];
const slotApiKey = 'item.apiKey';
return { args, headers, items, slotApiKey };
},
template: `<N8nSettingsLayout v-bind="args" show-back back-label="Back">${tablePage}</N8nSettingsLayout>`,
}),
args: {
fullWidth: true,
},
};
@@ -0,0 +1,104 @@
import { fireEvent, render, screen } from '@testing-library/vue';
import N8nSettingsLayout from './SettingsLayout.vue';
describe('N8nSettingsLayout', () => {
it('renders slotted content', () => {
render(N8nSettingsLayout, {
slots: { default: '<div data-test-id="content">page</div>' },
});
expect(screen.getByTestId('content')).toBeInTheDocument();
});
it('hides the back action by default', () => {
render(N8nSettingsLayout, { slots: { default: 'content' } });
expect(screen.queryByTestId('settings-back-button')).not.toBeInTheDocument();
});
it('defaults the back action label to "Back"', () => {
render(N8nSettingsLayout, {
props: { showBack: true },
slots: { default: 'content' },
});
const button = screen.getByTestId('settings-back-button');
expect(button).toHaveTextContent('Back');
expect(button).toHaveAccessibleName('Back');
});
it('shows a ghost back action with the given label when show-back is set', () => {
render(N8nSettingsLayout, {
props: { showBack: true, backLabel: 'Back to Security settings' },
slots: { default: 'content' },
});
const button = screen.getByTestId('settings-back-button');
expect(button).toBeInTheDocument();
expect(button.className).toContain('ghost');
expect(button).toHaveTextContent('Back to Security settings');
// The arrow icon is aria-hidden, so the label is the button's accessible name.
expect(button).toHaveAccessibleName('Back to Security settings');
});
it('emits back when the back action is clicked', async () => {
const { emitted } = render(N8nSettingsLayout, {
props: { showBack: true },
slots: { default: 'content' },
});
await fireEvent.click(screen.getByTestId('settings-back-button'));
expect(emitted().back).toHaveLength(1);
});
it('caps the content at the content max-width by default', () => {
const { container } = render(N8nSettingsLayout, {
slots: { default: 'content' },
});
const content = container.querySelector('[class*="content"]') as HTMLElement;
expect(content.className).not.toContain('fullWidth');
});
it('lets the content fill the padded container when fullWidth is set', () => {
const { container } = render(N8nSettingsLayout, {
props: { fullWidth: true },
slots: { default: 'content' },
});
const content = container.querySelector('[class*="content"]') as HTMLElement;
expect(content.className).toContain('fullWidth');
});
it('keeps the header a direct child of the centered content region in both modes', () => {
// The content region applies `margin-inline: auto` to its children, so a capped
// header stays centered on the page whether or not the content is full-width.
for (const fullWidth of [false, true]) {
const { container } = render(N8nSettingsLayout, {
props: { fullWidth },
slots: { default: '<header data-test-id="page-header">title</header>' },
});
const content = container.querySelector('[class*="content"]') as HTMLElement;
const header = content.querySelector('[data-test-id="page-header"]') as HTMLElement;
expect(header.parentElement).toBe(content);
}
});
it('renders the back action outside the centered content column', () => {
const { container } = render(N8nSettingsLayout, {
props: { showBack: true },
slots: { default: 'content' },
});
const content = container.querySelector('[class*="content"]') as HTMLElement;
const backButton = screen.getByTestId('settings-back-button');
// The back action lives in the full-width padded area, not inside the capped/centered content.
expect(backButton).toBeInTheDocument();
expect(content.contains(backButton)).toBe(false);
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
export interface SettingsLayoutProps {
/** Element/component to render as the layout container. */
tag?: string;
/** Show the ghost back action pinned to the top-left of the page. */
showBack?: boolean;
/** Label for the back action. */
backLabel?: string;
/**
* Let the content fill the full padded width instead of being capped at the
* content max-width. Use for pages with a wide table that should span the container.
*/
fullWidth?: boolean;
}
defineOptions({ name: 'N8nSettingsLayout' });
withDefaults(defineProps<SettingsLayoutProps>(), {
tag: 'div',
showBack: false,
backLabel: 'Back',
fullWidth: false,
});
const emit = defineEmits<{ back: [] }>();
</script>
<template>
<component :is="tag" :class="$style.layout">
<div v-if="showBack || $slots.back" :class="$style.backRow">
<slot name="back">
<N8nButton
variant="ghost"
size="small"
:class="$style.backButton"
data-test-id="settings-back-button"
@click="emit('back')"
>
<template #icon>
<N8nIcon icon="arrow-left" />
</template>
{{ backLabel }}
</N8nButton>
</slot>
</div>
<div :class="[$style.content, { [$style.fullWidth]: fullWidth }]">
<slot />
</div>
</component>
</template>
<style lang="scss" module>
.layout {
/* The single component-scoped width token; 45rem === 720px at a 16px root. */
--settings-content--max-width: 45rem;
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
width: 100%;
/* top | inline | bottom — bottom uses the larger 2xl token. */
padding: var(--spacing--lg) var(--spacing--lg) var(--spacing--2xl);
box-sizing: border-box;
}
.backRow {
display: flex;
width: 100%;
}
.backButton {
/* Pull the ghost button's inner padding so the arrow aligns to the page's 24px inset. */
margin-inline-start: calc(-1 * var(--spacing--xs));
}
.content {
width: 100%;
max-width: var(--settings-content--max-width);
margin-inline: auto;
/*
* Center each child within its own cap. The page header caps itself at
* --settings-content--max-width, so it stays centered on the page even when the
* content region goes full-width; full-width children (width: 100%) are unaffected.
*/
> * {
margin-inline: auto;
}
/*
* Vertical rhythm is owned solely by the FOLLOWING child's margin-block-start, so two
* adjacent margins never meet and the spacing never depends on margin-collapsing.
* Default rhythm for non-section direct children:
*/
> * + * {
margin-block-start: var(--spacing--lg); /* 24px */
}
/*
* Enforced page-header → content gap (48px). The page header renders a semantic <header>,
* so the element that follows it owns the larger gap. Kept here (not on the header's own
* margin) so it is deterministic, can't be overridden, and never relies on collapse.
*/
> header + * {
margin-block-start: var(--spacing--2xl); /* 48px */
}
}
.fullWidth {
max-width: none;
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsLayout.vue';
export type { SettingsLayoutProps } from './SettingsLayout.vue';
@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nSettingsPageHeader from './SettingsPageHeader.vue';
const meta = {
title: 'Instance Settings/Page Header',
component: N8nSettingsPageHeader,
argTypes: {
showDocsLink: { control: 'boolean' },
docsUrl: { control: 'text' },
docsLabel: { control: 'text' },
docsLeadingText: { control: 'text' },
},
parameters: {
docs: {
description: {
component:
'Page title with an optional 1-2 sentence description and an inline documentation link. The docs link is ON by default (`show-docs-link`), so every settings page links to docs — set `:show-docs-link="false"` to remove it, and provide `docs-url` so the link points somewhere (a dev warning fires if it is enabled without a URL). The header always caps itself at the content max-width (`--settings-content--max-width`, 45rem / 720px). The link renders inline at the end of the description in the description base color: the word is underlined and the trailing `↗` is not.',
},
},
},
} satisfies Meta<typeof N8nSettingsPageHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
// Wider than 720px to show the header capping itself at the content max-width.
const frame = (inner: string) => `<div style="max-width: 60rem;">${inner}</div>`;
export const Default: Story = {
render: (args) => ({
components: { N8nSettingsPageHeader },
setup: () => ({ args }),
template: frame('<N8nSettingsPageHeader v-bind="args" />'),
}),
args: {
title: 'This instance',
description:
'Plan, usage, version, updates, instance details, resources, and support for this n8n instance.',
docsUrl: 'https://docs.n8n.io',
},
};
export const CustomLeadingCopy: Story = {
render: (args) => ({
components: { N8nSettingsPageHeader },
setup: () => ({ args }),
template: frame('<N8nSettingsPageHeader v-bind="args" />'),
}),
args: {
title: 'API keys',
description: 'Use your API keys to control n8n programmatically.',
docsLeadingText: 'Read the ',
docsLabel: 'API reference',
docsUrl: 'https://docs.n8n.io/api/',
},
};
export const WithoutDocsLink: Story = {
render: (args) => ({
components: { N8nSettingsPageHeader },
setup: () => ({ args }),
template: frame('<N8nSettingsPageHeader v-bind="args" />'),
}),
args: {
title: 'Members',
description: 'People with access to this instance.',
showDocsLink: false,
},
};
export const TitleOnly: Story = {
render: (args) => ({
components: { N8nSettingsPageHeader },
setup: () => ({ args }),
template: frame('<N8nSettingsPageHeader v-bind="args" />'),
}),
args: {
title: 'Members',
showDocsLink: false,
},
};
export const LongDescription: Story = {
render: (args) => ({
components: { N8nSettingsPageHeader },
setup: () => ({ args }),
template: frame('<N8nSettingsPageHeader v-bind="args" />'),
}),
args: {
title: 'Page title',
description:
'Description of the page explaining what it does, followed up by a link to full feature documentation as the next sentence. It should not be overly long, rather 1-2 sentences.',
docsUrl: 'https://docs.n8n.io',
},
};
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/vue';
import N8nSettingsPageHeader from './SettingsPageHeader.vue';
describe('N8nSettingsPageHeader', () => {
it('renders the title as an h1 by default', () => {
render(N8nSettingsPageHeader, { props: { title: 'This instance', showDocsLink: false } });
const title = screen.getByText('This instance');
expect(title).toBeInTheDocument();
expect(title.tagName).toBe('H1');
});
it('renders the title with the requested heading tag', () => {
render(N8nSettingsPageHeader, {
props: { title: 'This instance', headingTag: 'h2', showDocsLink: false },
});
expect(screen.getByText('This instance').tagName).toBe('H2');
});
it('renders the description', () => {
render(N8nSettingsPageHeader, {
props: {
title: 'This instance',
description: 'Plan, usage and version.',
showDocsLink: false,
},
});
expect(screen.getByText('Plan, usage and version.')).toBeInTheDocument();
});
it('renders the docs link on by default with the default "documentation" label', () => {
render(N8nSettingsPageHeader, {
props: {
title: 'This instance',
description: 'Plan, usage and version.',
docsUrl: 'https://docs.n8n.io',
},
});
const link = screen.getByRole('link', { name: 'documentation' });
expect(link).toHaveAttribute('href', 'https://docs.n8n.io');
});
it('hides the docs link when showDocsLink is false', () => {
render(N8nSettingsPageHeader, {
props: {
title: 'This instance',
description: 'Plan, usage and version.',
docsUrl: 'https://docs.n8n.io',
showDocsLink: false,
},
});
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-page-header-docs')).not.toBeInTheDocument();
});
it('renders custom leading copy and a custom label', () => {
render(N8nSettingsPageHeader, {
props: {
title: 'API keys',
description: 'Use your API keys to control n8n programmatically.',
docsLeadingText: 'Read the ',
docsLabel: 'API reference',
docsUrl: 'https://docs.n8n.io/api/',
},
});
expect(screen.getByText(/Read the/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'API reference' })).toBeInTheDocument();
});
it('renders the docs link inline as an underlined word followed by a non-underlined arrow', () => {
render(N8nSettingsPageHeader, {
props: {
title: 'This instance',
description: 'Plan, usage and version.',
docsUrl: 'https://docs.n8n.io',
},
});
const link = screen.getByRole('link', { name: 'documentation' });
const label = link.querySelector('[class*="docsLabel"]') as HTMLElement;
expect(label).toHaveTextContent('documentation');
const arrow = link.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(arrow).toHaveTextContent('↗');
// The arrow is decorative, so it is hidden from assistive tech and excluded from the link name.
expect(arrow).toHaveAttribute('aria-hidden', 'true');
expect(link).toHaveAccessibleName('documentation');
});
it('warns and renders a non-navigational placeholder when shown without a docsUrl', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(N8nSettingsPageHeader, {
props: { title: 'This instance', description: 'Plan, usage and version.' },
});
// Placeholder text is still shown to nudge the developer, but it is not a real link.
const placeholder = screen.getByTestId('settings-page-header-docs');
expect(placeholder).toHaveTextContent('documentation');
expect(placeholder).not.toHaveAttribute('href');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('docsUrl'));
warn.mockRestore();
});
});
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, useSlots, watchEffect } from 'vue';
import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText';
export interface SettingsPageHeaderProps {
/** Page title. */
title: string;
/** Optional 1-2 sentence description. */
description?: string;
/**
* Whether to render the inline documentation link at the end of the header. On by default
* so every settings page links to docs; set `:show-docs-link="false"` to remove it.
*/
showDocsLink?: boolean;
/** Documentation link target. When omitted (while the link is shown) a dev warning fires. */
docsUrl?: string;
/** The underlined link word. */
docsLabel?: string;
/** Leading copy rendered before the link word (e.g. "Learn more in the "). */
docsLeadingText?: string;
/** Heading element for the page title. */
headingTag?: string;
}
defineOptions({ name: 'N8nSettingsPageHeader' });
const props = withDefaults(defineProps<SettingsPageHeaderProps>(), {
description: undefined,
showDocsLink: true,
docsUrl: undefined,
docsLabel: 'documentation',
docsLeadingText: 'Learn more in the ',
headingTag: 'h1',
});
const slots = useSlots();
const hasDescription = computed(() => Boolean(props.description || slots.description));
// Default-on means a developer who forgets to wire a docs URL is nudged (not silently broken):
// the link word still renders as a placeholder and a dev-only warning prompts them to act.
if (import.meta.env.DEV) {
watchEffect(() => {
if (props.showDocsLink && !props.docsUrl) {
console.warn(
'[N8nSettingsPageHeader] The docs link is enabled but no `docsUrl` was provided. ' +
'Set `docs-url` to your documentation page, or pass `:show-docs-link="false"` to remove it.',
);
}
});
}
</script>
<template>
<header :class="$style.header">
<N8nHeading :tag="headingTag" :class="$style.title" step="xl" color="text-dark">
{{ title }}
</N8nHeading>
<p v-if="hasDescription || showDocsLink" :class="$style.description">
<slot name="description">
<N8nText v-if="description" size="medium" color="text-base">{{ description }}</N8nText>
</slot>
<!--
The separating space sits OUTSIDE the nowrap docs phrase so the whole "leading copy +
link + arrow" can wrap to the next line as a single unit; only added after a description.
-->
<template v-if="showDocsLink"
>{{ hasDescription ? ' ' : ''
}}<span :class="$style.docsPhrase"
><N8nText size="medium" color="text-base">{{ docsLeadingText }}</N8nText
><a
:class="$style.docsLink"
:href="docsUrl || undefined"
target="_blank"
rel="noopener noreferrer"
data-test-id="settings-page-header-docs"
><span :class="$style.docsLabel">{{ docsLabel }}</span
><span aria-hidden="true"></span></a
></span
></template
>
</p>
</header>
</template>
<style lang="scss" module>
.header {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
width: 100%;
/* The header column stays capped even when the layout content is full-width. */
max-width: var(--settings-content--max-width, 45rem);
/*
* The 48px gap to the content below is owned and enforced by N8nSettingsLayout
* (`.content > header + *`), not by an external margin here, so it stays deterministic
* and never relies on margin-collapsing.
*/
}
.title {
letter-spacing: var(--letter-spacing--tight);
}
.description {
margin: 0;
display: inline;
/* Intentionally the standard body line-height (lg). */
line-height: var(--line-height--lg);
}
/* The inline N8nText pieces already use the body line-height (lg); keep them consistent. */
.description :global(.n8n-text) {
line-height: var(--line-height--lg);
}
/* Keeps "leading copy + link + arrow" together so the docs sentence wraps as a single unit. */
.docsPhrase {
white-space: nowrap;
}
.docsLink {
/* Reads as part of the description: same base text color, no link/primary color. */
color: var(--text-color--subtle);
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
text-decoration: none;
cursor: pointer;
}
/* Only the link word is underlined; the ↗ indicator (plain span) stays bare. */
.docsLabel {
text-decoration: underline;
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsPageHeader.vue';
export type { SettingsPageHeaderProps } from './SettingsPageHeader.vue';
@@ -0,0 +1,468 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import N8nSettingsRow from './SettingsRow.vue';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nInput from '../N8nInput';
import N8nSettingsRowConfigure from '../N8nSettingsRowConfigure';
import N8nSettingsRowGroup from '../N8nSettingsRowGroup';
import N8nSwitch from '../N8nSwitch';
import N8nText from '../N8nText';
const meta = {
title: 'Instance Settings/Settings Row',
component: N8nSettingsRow,
argTypes: {
layout: { control: 'select', options: ['horizontal', 'vertical', 'custom'] },
description: {
control: 'text',
description:
'Short, scannable, plain-language summary of the setting (ideally one sentence). Keep it concise — link to the docs for anything longer rather than writing long inline copy.',
},
maxDescriptionLines: { control: { type: 'number', min: 1, max: 3 } },
truncateTitle: { control: 'boolean' },
showDivider: { control: 'boolean' },
showVisual: { control: 'boolean' },
actionMaxWidth: { control: 'text' },
actionFill: { control: 'boolean' },
expandLabel: { control: 'text' },
collapseLabel: { control: 'text' },
hoverable: { control: 'boolean' },
clickable: { control: 'boolean' },
revealActionsOnHover: { control: 'boolean' },
},
parameters: {
docs: {
description: {
component:
'The core description-list row: left info (title/description + optional leading visual) and an action slot, arranged horizontally, vertically, or as a fully custom full-width slot. In horizontal rows, action controls should use the medium size (`size="medium"`) so their height matches input fields and stays consistent across rows.\n\n**Writing the description:** keep it short, scannable, and plain-language — one clear sentence stating what the setting does or its current state. Avoid long, paragraph-length copy; descriptions clamp to `maxDescriptionLines` (max 3) and reveal the rest in a tooltip on hover, but that is a safety net, not a license to write long text. If a setting needs more explanation, link to the docs rather than inlining the detail.',
},
},
},
} satisfies Meta<typeof N8nSettingsRow>;
export default meta;
type Story = StoryObj<typeof meta>;
const card = (inner: string) =>
`<div style="max-width: 45rem;"><N8nSettingsRowGroup>${inner}</N8nSettingsRowGroup></div>`;
export const Horizontal: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow v-bind="args">
<template #action><N8nButton variant="outline" size="medium" label="Change password" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Password',
description: 'Last changed 4 months ago.',
layout: 'horizontal',
},
};
export const Vertical: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nInput },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow v-bind="args">
<template #action><N8nInput placeholder="https://example.com/webhook" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Webhook URL',
description: 'The full action below gets the entire row width.',
layout: 'vertical',
},
};
// Bordered, rounded metrics card: three equal columns (tiles) separated by vertical dividers,
// built from DS border/radius/spacing tokens. Each tile shows a metric title, a "Last 7 days"
// sublabel, the big bold value, and either a colored trend delta (success/danger) or the muted
// "/ unlimited" suffix.
const metricTilesCard = `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; border: var(--border-width, 1px) solid var(--border-color--subtle); border-radius: var(--radius--xs); overflow: clip;">
<div
v-for="(metric, index) in metrics"
:key="metric.title"
:style="{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing--2xs)',
padding: 'var(--spacing--sm)',
borderInlineStart: index > 0 ? 'var(--border-width, 1px) solid var(--border-color--subtle)' : '',
}"
>
<div style="display: flex; flex-direction: column; gap: var(--spacing--5xs);">
<N8nText size="small" color="text-base" tag="div">{{ metric.title }}</N8nText>
<N8nText size="small" color="text-light" tag="div">Last 7 days</N8nText>
</div>
<div style="display: flex; align-items: center; gap: var(--spacing--2xs);">
<N8nText size="xlarge" bold color="text-dark" tag="span">{{ metric.value }}</N8nText>
<N8nText v-if="metric.suffix" size="small" color="text-light" tag="span">{{ metric.suffix }}</N8nText>
<span v-else style="display: inline-flex; align-items: center; gap: var(--spacing--5xs);">
<N8nIcon icon="triangle" :color="metric.delta" size="xsmall" />
<N8nText size="small" :color="metric.delta" tag="span">{{ metric.deltaText }}</N8nText>
</span>
</div>
</div>
</div>
`;
export const Custom: Story = {
render: () => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nText, N8nIcon, N8nButton },
setup() {
// `delta` drives the colored trend (success/danger); `suffix` is the muted
// "/ unlimited" variant that has no trend arrow.
const metrics = [
{ title: 'Prod. executions', value: '23,432', delta: 'success', deltaText: '0.5pp' },
{ title: 'Active workflows', value: '865', suffix: '/ unlimited' },
{ title: 'Active users', value: '1.9%', delta: 'danger', deltaText: '0.5pp' },
];
return { metrics };
},
// "Usage" is a `vertical` row: its title is "Usage" and the full-width slot below holds
// the bordered three-column metrics card. It composes inside a row group next to plain
// horizontal rows (Plan, Billing) so the rich custom content reads naturally among
// regular settings rows.
template: card(`
<N8nSettingsRow layout="vertical" title="Usage">
<template #action>${metricTilesCard}</template>
</N8nSettingsRow>
<N8nSettingsRow title="Plan">
<template #action><N8nText size="medium" color="text-dark">Enterprise</N8nText></template>
</N8nSettingsRow>
<N8nSettingsRow title="Billing">
<template #action><N8nButton variant="outline" size="medium" label="Manage plan" /></template>
</N8nSettingsRow>
`),
}),
parameters: {
docs: {
description: {
story:
'A `vertical` row whose title is "Usage" and whose full-width slot below holds a bordered three-column metrics card (with success/danger trend deltas), composed inside a row group alongside plain Plan/Billing rows. This is the recommended pattern for "a labelled row with rich custom content below the title".',
},
},
},
};
export const WithVisual: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton, N8nIcon },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow v-bind="args">
<template #visual><N8nIcon icon="globe" /></template>
<template #action><N8nButton variant="outline" size="small" label="Log out" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Chrome 138 on macOS',
description: 'Gdynia, Poland · active now',
showVisual: true,
},
};
export const WithoutDescription: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nSwitch },
setup() {
const enabled = ref(false);
return { args, enabled };
},
template: card(`
<N8nSettingsRow v-bind="args">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Compact mode',
},
};
export const DescriptionTruncation: Story = {
render: () => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nSwitch },
setup() {
const telemetry = ref(true);
const heartbeat = ref(true);
const longDescription =
'Share anonymous usage data and diagnostic logs so we can understand how workflows are built, prioritise the improvements that matter most, and catch regressions early. You can turn this off at any time, and we never collect the contents of your workflows, your credentials, or the data your executions process.';
return { telemetry, heartbeat, longDescription };
},
template: card(`
<N8nSettingsRow
title="Telemetry & diagnostics"
:description="longDescription"
:max-description-lines="2"
>
<template #action><N8nSwitch v-model="telemetry" /></template>
</N8nSettingsRow>
<N8nSettingsRow
title="Instance heartbeat"
description="Sends a lightweight ping so you can see when this instance is online."
>
<template #action><N8nSwitch v-model="heartbeat" /></template>
</N8nSettingsRow>
`),
}),
parameters: {
docs: {
description: {
story:
"The description clamps to `maxDescriptionLines` (max 3) with an ellipsis. When the copy actually overflows the clamp — like the first row — hovering (or focusing) it reveals the full text in a tooltip. Rows whose description already fits — like the second — show no tooltip, so the affordance is never redundant. Truncation is detected from the rendered element and re-evaluated on resize, so it stays correct as the row width changes.\n\n**Note:** the first row's description is unrealistically long purely to demonstrate the truncation + tooltip behavior — it is not a recommended pattern. In real settings, keep descriptions short and scannable (see the component docs) and link out for any longer detail.",
},
},
},
};
export const ActionMaxWidth: Story = {
render: () => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nInput, N8nButton },
template: card(`
<N8nSettingsRow
title="Fill · 50% (default)"
description="The recommended horizontal default: the action fills up to half the 720px row."
action-fill
action-max-width="50%"
>
<template #action><N8nInput style="width: 100%" placeholder="Fills 50%" /></template>
</N8nSettingsRow>
<N8nSettingsRow
title="Fill · 20%"
description="A compact action; the info keeps the remaining ~80%."
action-fill
action-max-width="20%"
>
<template #action><N8nInput style="width: 100%" placeholder="20%" /></template>
</N8nSettingsRow>
<N8nSettingsRow
title="Fill · 5%"
description="A minimal action — almost all the space goes to the info."
action-fill
action-max-width="5%"
>
<template #action>
<N8nButton style="width: 100%" variant="outline" size="medium" icon-only icon="ellipsis-vertical" aria-label="More" />
</template>
</N8nSettingsRow>
<N8nSettingsRow
title="Fill · 100% requested → still ~50%"
description="In horizontal, a filled action shares the row with the info, so it stays about half even when you ask for more."
action-fill
action-max-width="100%"
>
<template #action><N8nInput style="width: 100%" placeholder="Still ~50%" /></template>
</N8nSettingsRow>
<N8nSettingsRow
title="Hug (default sizing)"
description="Without fill, the action sizes to its own content and sits on the right."
>
<template #action><N8nButton variant="outline" size="medium" label="Edit" /></template>
</N8nSettingsRow>
<N8nSettingsRow
title="Override · uncapped (false)"
description="action-max-width=false removes the cap, so intrinsically wide content can exceed 50%."
:action-max-width="false"
>
<template #action><N8nInput style="width: 30rem" placeholder="Wider than 50% — uncapped" /></template>
</N8nSettingsRow>
`),
}),
parameters: {
docs: {
description: {
story:
'`actionMaxWidth` (horizontal only) accepts any CSS max-width string — percentages ("50%", the default), absolute lengths ("30rem", "200px") — or `false` to remove the cap. By default the action **hugs** its content; add `action-fill` so it **fills** up to the cap. A filled action also shares the row with the info, so it never grows past ~50% in horizontal even when the cap is higher; use `:action-max-width="false"` with intrinsically wide content to exceed that.',
},
},
},
};
export const NoDivider: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow title="2 other active sessions" :show-divider="false">
<template #action><N8nButton variant="outline" size="small" label="Revoke all" /></template>
</N8nSettingsRow>
<N8nSettingsRow v-bind="args">
<template #action><N8nButton variant="outline" size="small" label="Revoke" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Safari on iPhone',
description: 'Gdynia, Poland · last seen 4 hours ago',
},
};
export const Expandable: Story = {
render: () => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nSwitch, N8nButton, N8nInput },
setup() {
// The switch in the action slot owns the expanded state via `v-model`; the row
// animates its `#expanded` region open/closed in response. `:disclosure="false"`
// hides the built-in chevron since the switch is the trigger here.
const enabled = ref(true);
return { enabled };
},
template: card(`
<N8nSettingsRow
title="Single sign-on (SSO)"
description="Turn on to reveal the SSO configuration below."
expandable
:disclosure="false"
v-model="enabled"
>
<template #action><N8nSwitch v-model="enabled" /></template>
<template #expanded>
<N8nSettingsRow title="Identity provider URL" layout="vertical">
<template #action><N8nInput placeholder="https://idp.example.com/sso" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Require SSO for all members" description="Members must sign in through your identity provider.">
<template #action><N8nButton variant="outline" size="medium" label="Configure" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Test connection" :show-divider="false">
<template #action><N8nButton variant="outline" size="medium" label="Run test" /></template>
</N8nSettingsRow>
</template>
</N8nSettingsRow>
`),
}),
parameters: {
docs: {
description: {
story:
'Stateful disclosure: the row exposes the expanded state through `v-model`, so any control can drive it. Here a switch in the action slot reveals nested settings rows with a ~200ms height + fade + blur animation (respecting `prefers-reduced-motion`).',
},
},
},
};
export const ExpandableChevron: Story = {
render: () => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton },
setup() {
const expanded = ref(false);
return { expanded };
},
// The built-in chevron is the default trigger affordance: it carries `aria-expanded` /
// `aria-controls` and rotates to reflect state. No `#action` control is required.
template: card(`
<N8nSettingsRow
title="Advanced options"
description="Use the chevron to reveal the additional settings."
expandable
v-model="expanded"
>
<template #expanded>
<N8nSettingsRow title="Beta features" description="Opt into experimental functionality.">
<template #action><N8nButton variant="outline" size="medium" label="Manage" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Reset to defaults" :show-divider="false">
<template #action><N8nButton variant="outline" size="medium" label="Reset" /></template>
</N8nSettingsRow>
</template>
</N8nSettingsRow>
`),
}),
parameters: {
docs: {
description: {
story:
'When no action control drives the state, the built-in chevron disclosure (default `disclosure: true`) is the trigger — a text label ("View more" → "Show less", customizable via `expandLabel`/`collapseLabel`) beside a rotating chevron, fully keyboard operable with `aria-expanded`/`aria-controls`.',
},
},
},
};
export const Hoverable: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow v-bind="args" hoverable>
<template #action><N8nButton variant="outline" size="small" label="Manage" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Hover me',
description: 'A subtle hover background highlights the row.',
},
};
export const Clickable: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nSettingsRowConfigure },
setup() {
const onRowClick = () => alert('Row clicked');
return { args, onRowClick };
},
template: card(`
<N8nSettingsRow v-bind="args" clickable @click="onRowClick">
<template #action><N8nSettingsRowConfigure /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Passkey',
description: 'Whole-row clickable with a text + chevron configure affordance.',
},
};
export const ConfigureWithStatus: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nSettingsRowConfigure },
setup() {
const onRowClick = () => alert('Configure');
return { args, onRowClick };
},
template: card(`
<N8nSettingsRow v-bind="args" clickable @click="onRowClick">
<template #action><N8nSettingsRowConfigure value="2 of 3 devices" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="OAuth applications" description="Apps authorized to access your account." clickable @click="onRowClick">
<template #action><N8nSettingsRowConfigure /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Two-factor authentication',
description:
'The affordance shows "Configure" when unset, or the configured-state text once set up.',
},
};
export const RevealActionsOnHover: Story = {
render: (args) => ({
components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton, N8nIcon },
setup: () => ({ args }),
template: card(`
<N8nSettingsRow v-bind="args" hoverable reveal-actions-on-hover show-visual>
<template #visual><N8nIcon icon="hard-drive" /></template>
<template #action><N8nButton variant="outline" size="small" label="Log out" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Safari on iPhone" description="Gdynia, Poland · last seen 4 hours ago" hoverable reveal-actions-on-hover show-visual>
<template #visual><N8nIcon icon="globe" /></template>
<template #action><N8nButton variant="outline" size="small" label="Revoke" /></template>
</N8nSettingsRow>
`),
}),
args: {
title: 'Chrome 138 on macOS',
description: 'Gdynia, Poland · active now. Hover (or focus) to reveal the action.',
},
};
@@ -0,0 +1,555 @@
import { fireEvent, render, screen } from '@testing-library/vue';
import { nextTick } from 'vue';
import N8nSettingsRow from './SettingsRow.vue';
describe('N8nSettingsRow', () => {
it('renders title and description', () => {
render(N8nSettingsRow, {
props: { title: 'My setting', description: 'What it does' },
});
expect(screen.getByText('My setting')).toBeInTheDocument();
expect(screen.getByText('What it does')).toBeInTheDocument();
});
it('defaults to the horizontal layout', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title' },
});
const row = container.querySelector('[data-layout]');
expect(row?.getAttribute('data-layout')).toBe('horizontal');
});
it.each(['horizontal', 'vertical', 'custom'] as const)('renders the %s layout', (layout) => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', layout },
slots: { default: '<div data-test-id="custom-content">content</div>' },
});
expect(container.querySelector(`[data-layout="${layout}"]`)).toBeInTheDocument();
});
it('renders the full-width default slot only in the custom layout', () => {
render(N8nSettingsRow, {
props: { layout: 'custom' },
slots: { default: '<div data-test-id="custom-content">content</div>' },
});
expect(screen.getByTestId('custom-content')).toBeInTheDocument();
});
it('caps the action width at 50% by default in the horizontal layout', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title' },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.style.maxWidth).toBe('50%');
});
it('allows overriding the action max-width', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', actionMaxWidth: '12rem' },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.style.maxWidth).toBe('12rem');
});
it('removes the action max-width cap when actionMaxWidth is false', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', actionMaxWidth: false },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.style.maxWidth).toBe('');
});
it('does not apply the action max-width in the vertical layout', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', layout: 'vertical' },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.style.maxWidth).toBe('');
});
it('lets the action fill its width in the horizontal layout when actionFill is set', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', actionFill: true },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.className).toContain('actionFill');
});
it('hugs the action (no fill) by default', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title' },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.className).not.toContain('actionFill');
});
it('does not apply actionFill outside the horizontal layout', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', layout: 'vertical', actionFill: true },
slots: { action: '<button>Do</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.className).not.toContain('actionFill');
});
it('shows the visual slot when provided', () => {
render(N8nSettingsRow, {
props: { title: 'Title' },
slots: { visual: '<span data-test-id="device-icon">icon</span>' },
});
expect(screen.getByTestId('settings-row-visual')).toBeInTheDocument();
expect(screen.getByTestId('device-icon')).toBeInTheDocument();
});
it('hides the visual slot by default', () => {
render(N8nSettingsRow, { props: { title: 'Title' } });
expect(screen.queryByTestId('settings-row-visual')).not.toBeInTheDocument();
});
it('renders the divider by default', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title' },
});
expect(container.querySelector('[data-test-id="settings-row-divider"]')).toBeInTheDocument();
});
it('does not render the divider when show-divider is false', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', showDivider: false },
});
expect(
container.querySelector('[data-test-id="settings-row-divider"]'),
).not.toBeInTheDocument();
});
it('clamps the description to a maximum of 3 lines', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', description: 'Long text', maxDescriptionLines: 8 },
});
const description = container.querySelector('[class*="description"]') as HTMLElement;
expect(description.style.getPropertyValue('--settings-row--description-lines')).toBe('3');
});
it('uses the configured description line count below the clamp', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', description: 'Long text', maxDescriptionLines: 2 },
});
const description = container.querySelector('[class*="description"]') as HTMLElement;
expect(description.style.getPropertyValue('--settings-row--description-lines')).toBe('2');
});
it('renders custom info slot content over the title/description', () => {
render(N8nSettingsRow, {
props: { title: 'Default title' },
slots: { info: '<div data-test-id="custom-info">custom</div>' },
});
expect(screen.getByTestId('custom-info')).toBeInTheDocument();
expect(screen.queryByText('Default title')).not.toBeInTheDocument();
});
it('applies the hoverable class when hoverable is set', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', hoverable: true },
});
const row = container.querySelector('[data-layout]') as HTMLElement;
expect(row.className).toContain('hoverable');
});
it('does not apply the hoverable class by default', () => {
const { container } = render(N8nSettingsRow, { props: { title: 'Title' } });
const row = container.querySelector('[data-layout]') as HTMLElement;
expect(row.className).not.toContain('hoverable');
});
describe('clickable', () => {
it('exposes button semantics and an accessible name from the title', () => {
render(N8nSettingsRow, { props: { title: 'Passkey', clickable: true } });
const row = screen.getByRole('button', { name: 'Passkey' });
expect(row).toHaveAttribute('tabindex', '0');
});
it('emits click when the row is clicked', async () => {
const { container, emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', clickable: true },
});
await fireEvent.click(container.querySelector('[data-layout]') as HTMLElement);
expect(emitted().click).toHaveLength(1);
});
it.each(['Enter', ' '])('activates with the %s key', async (key) => {
const { container, emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', clickable: true },
});
await fireEvent.keyDown(container.querySelector('[data-layout]') as HTMLElement, { key });
expect(emitted().click).toHaveLength(1);
});
it('does not emit click when a keydown originates from a nested control', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', clickable: true },
slots: { action: '<button data-test-id="nested-control">Manage</button>' },
});
await fireEvent.keyDown(screen.getByTestId('nested-control'), { key: 'Enter' });
await fireEvent.keyDown(screen.getByTestId('nested-control'), { key: ' ' });
expect(emitted().click).toBeUndefined();
});
it('does not emit click when a click originates from a nested control', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', clickable: true },
slots: { action: '<button data-test-id="nested-control">Manage</button>' },
});
await fireEvent.click(screen.getByTestId('nested-control'));
expect(emitted().click).toBeUndefined();
});
it('still emits click for clicks on non-interactive row content', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', description: 'Sign in with your device.', clickable: true },
});
await fireEvent.click(screen.getByText('Passkey'));
expect(emitted().click).toHaveLength(1);
});
it('does not emit click for non-activation keys', async () => {
const { container, emitted } = render(N8nSettingsRow, {
props: { title: 'Passkey', clickable: true },
});
await fireEvent.keyDown(container.querySelector('[data-layout]') as HTMLElement, {
key: 'Tab',
});
expect(emitted().click).toBeUndefined();
});
it('does not emit click or expose button semantics when not clickable', async () => {
const { container, emitted } = render(N8nSettingsRow, { props: { title: 'Passkey' } });
const row = container.querySelector('[data-layout]') as HTMLElement;
expect(row).not.toHaveAttribute('role', 'button');
await fireEvent.click(row);
expect(emitted().click).toBeUndefined();
});
});
describe('expandable', () => {
it('does not render the expand region unless expandable', () => {
render(N8nSettingsRow, {
props: { title: 'Title' },
slots: { expanded: '<div data-test-id="expanded-content">details</div>' },
});
expect(screen.queryByRole('region')).not.toBeInTheDocument();
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
});
it('renders the expand region when expandable, mounting slot content on first expand', async () => {
const { rerender } = render(N8nSettingsRow, {
props: { title: 'Title', expandable: true },
slots: { expanded: '<div data-test-id="expanded-content">details</div>' },
});
// Collapsed rows keep their (potentially heavy) expanded content unmounted until opened.
expect(screen.getByRole('region')).toBeInTheDocument();
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
await rerender({ modelValue: true });
expect(screen.getByTestId('expanded-content')).toBeInTheDocument();
// Stays mounted after collapse so the closing animation has content to clip.
await rerender({ modelValue: false });
expect(screen.getByTestId('expanded-content')).toBeInTheDocument();
});
it('renders the built-in chevron trigger wired to the region by default', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true },
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
const region = screen.getByRole('region');
expect(trigger).toHaveAttribute('aria-controls', region.id);
});
it('reflects the collapsed state via aria-expanded and data-expanded', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, modelValue: false },
});
expect(screen.getByRole('button', { name: 'Toggle Title' })).toHaveAttribute(
'aria-expanded',
'false',
);
expect(screen.getByRole('region')).toHaveAttribute('data-expanded', 'false');
});
it('reflects the expanded state via aria-expanded and data-expanded', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, modelValue: true },
});
expect(screen.getByRole('button', { name: 'Toggle Title' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('region')).toHaveAttribute('data-expanded', 'true');
});
it('emits update:modelValue when the chevron trigger is clicked', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, modelValue: false },
});
await fireEvent.click(screen.getByRole('button', { name: 'Toggle Title' }));
expect(emitted()['update:modelValue']).toEqual([[true]]);
});
it('works uncontrolled: the chevron toggles its own state', async () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true },
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('region')).toHaveAttribute('data-expanded', 'true');
});
it('hides the built-in chevron when disclosure is false', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, disclosure: false },
});
expect(screen.queryByRole('button', { name: 'Toggle Title' })).not.toBeInTheDocument();
expect(screen.getByRole('region')).toBeInTheDocument();
});
it('does not toggle the chevron click into a clickable row', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, clickable: true },
});
await fireEvent.click(screen.getByRole('button', { name: 'Toggle Title' }));
expect(emitted().click).toBeUndefined();
});
it('shows the default "View more" label while collapsed', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, modelValue: false },
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
expect(trigger).toHaveTextContent('View more');
expect(trigger).not.toHaveTextContent('Show less');
});
it('shows the default "Show less" label while expanded', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, modelValue: true },
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
expect(trigger).toHaveTextContent('Show less');
expect(trigger).not.toHaveTextContent('View more');
});
it('toggles the label text when the trigger is clicked (uncontrolled)', async () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true },
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
expect(trigger).toHaveTextContent('View more');
await fireEvent.click(trigger);
expect(trigger).toHaveTextContent('Show less');
});
it('honours custom expand/collapse labels', async () => {
render(N8nSettingsRow, {
props: {
title: 'Title',
expandable: true,
expandLabel: 'Show details',
collapseLabel: 'Hide details',
},
});
const trigger = screen.getByRole('button', { name: 'Toggle Title' });
expect(trigger).toHaveTextContent('Show details');
await fireEvent.click(trigger);
expect(trigger).toHaveTextContent('Hide details');
});
it('keeps the aria-label independent of the visible label', () => {
render(N8nSettingsRow, {
props: { title: 'Title', expandable: true, expandLabel: 'Show details' },
});
expect(screen.getByRole('button', { name: 'Toggle Title' })).toHaveTextContent(
'Show details',
);
});
});
describe('revealActionsOnHover', () => {
it('marks the action region as reveal-on-hover', () => {
const { container } = render(N8nSettingsRow, {
props: { title: 'Title', revealActionsOnHover: true },
slots: { action: '<button>Log out</button>' },
});
const action = container.querySelector('[class*="action"]') as HTMLElement;
expect(action.className).toContain('revealActions');
});
it('does not bubble a revealed action click to a clickable row', async () => {
const { emitted } = render(N8nSettingsRow, {
props: { title: 'Title', clickable: true, revealActionsOnHover: true },
slots: { action: '<button data-test-id="reveal-action">Log out</button>' },
});
await fireEvent.click(screen.getByTestId('reveal-action'));
expect(emitted().click).toBeUndefined();
});
});
describe('description truncation tooltip', () => {
// Stub N8nTooltip so we can deterministically assert whether it is enabled (truncated) or
// disabled (fits), without depending on the floating-tooltip internals.
const TooltipStub = {
name: 'N8nTooltip',
props: ['content', 'disabled', 'placement'],
template:
'<div data-test-id="description-tooltip" :data-disabled="String(disabled)" :data-content="content"><slot /></div>',
};
const originalScrollHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'scrollHeight',
);
const originalClientHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'clientHeight',
);
// jsdom reports 0 for layout metrics, so fake them to model a clamped (overflowing) vs a
// fitting description.
const mockGeometry = (scrollHeight: number, clientHeight: number) => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => clientHeight,
});
};
const renderWithTooltip = async (props: Record<string, unknown>) => {
const utils = render(N8nSettingsRow, {
props,
global: { stubs: { N8nTooltip: TooltipStub } },
});
// Let the post-flush truncation watcher run against the now-mounted element.
await nextTick();
await nextTick();
return utils;
};
afterEach(() => {
const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
if (originalScrollHeight) {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', originalScrollHeight);
} else {
delete proto.scrollHeight;
}
if (originalClientHeight) {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight);
} else {
delete proto.clientHeight;
}
});
it('enables the tooltip with the full description when it is truncated', async () => {
mockGeometry(80, 32);
await renderWithTooltip({
title: 'Title',
description: 'A very long description that overflows the clamp.',
});
const tooltip = screen.getByTestId('description-tooltip');
expect(tooltip.getAttribute('data-disabled')).toBe('false');
expect(tooltip.getAttribute('data-content')).toBe(
'A very long description that overflows the clamp.',
);
});
it('disables the tooltip when the description fits within the clamp', async () => {
mockGeometry(32, 32);
await renderWithTooltip({ title: 'Title', description: 'Short description.' });
expect(screen.getByTestId('description-tooltip').getAttribute('data-disabled')).toBe('true');
});
it('keeps the full description in the DOM even when it is visually truncated', async () => {
mockGeometry(80, 32);
const description = 'Full description text that is only clamped visually.';
await renderWithTooltip({ title: 'Title', description });
expect(screen.getByText(description)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,565 @@
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core';
import { computed, ref, watch, useId, useSlots } from 'vue';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
import N8nTooltip from '../N8nTooltip';
export type SettingsRowLayout = 'horizontal' | 'vertical' | 'custom';
export interface SettingsRowProps {
/** Left title (text-dark, 14/medium). Optional when the `info` slot is used. */
title?: string;
/** Left description (text-light, 12/regular). Wraps and clamps to `maxDescriptionLines`. */
description?: string;
/** Arrangement of info vs action. `custom` hands the whole row to the default slot. */
layout?: SettingsRowLayout;
/** Soft default 2; hard clamped to a maximum of 3 lines regardless of the value passed. */
maxDescriptionLines?: number;
/** Single-line ellipsis title. */
truncateTitle?: boolean;
/** Horizontal layout only: caps the action width (default 50%). `false` removes the cap. */
actionMaxWidth?: string | false;
/**
* Horizontal layout only: let the action grow to fill its available width up to
* `actionMaxWidth` (Figma "fill"). Default `false` hugs the action to its content (Figma
* "hug"). Pair with a slot child that is `width: 100%` so it visibly fills the slot.
*/
actionFill?: boolean;
/** Bottom divider. The last row in a group auto-hides its divider via CSS. */
showDivider?: boolean;
/** Show the leading visual slot. Implicitly true when the `visual` slot is filled. */
showVisual?: boolean;
/**
* Enables the disclosure region (`#expanded` slot) that animates open/closed. Pair with
* `v-model` (`modelValue`): bind it to whatever control owns the state — a switch in the
* `#action` slot, a button, or the built-in chevron trigger.
*/
expandable?: boolean;
/**
* Renders the built-in chevron disclosure trigger (the default affordance). Set `false`
* when an `#action` control (e.g. a switch) is the sole trigger.
*/
disclosure?: boolean;
/** Built-in disclosure trigger label shown beside the chevron while collapsed. */
expandLabel?: string;
/** Built-in disclosure trigger label shown beside the chevron while expanded. */
collapseLabel?: string;
/** Shows a subtle hover background on the row. Implied by `clickable`. */
hoverable?: boolean;
/**
* Turns the whole row into a single clickable control: pointer cursor, hover and
* active states, `role="button"` + keyboard (Enter/Space), and emits `@click`. Pair it
* with a `N8nSettingsRowConfigure` affordance in the `#action` slot.
*/
clickable?: boolean;
/**
* Hides the `#action` slot until the row is hovered or contains keyboard focus. Use for
* secondary actions (e.g. "Log out"/"Revoke"). Revealed action clicks never bubble to a
* `clickable` row.
*/
revealActionsOnHover?: boolean;
}
defineOptions({ name: 'N8nSettingsRow' });
const props = withDefaults(defineProps<SettingsRowProps>(), {
title: undefined,
description: undefined,
layout: 'horizontal',
maxDescriptionLines: 2,
truncateTitle: true,
actionMaxWidth: '50%',
actionFill: false,
showDivider: true,
showVisual: false,
expandable: false,
disclosure: true,
expandLabel: 'View more',
collapseLabel: 'Show less',
hoverable: false,
clickable: false,
revealActionsOnHover: false,
});
const emit = defineEmits<{
click: [event: MouseEvent | KeyboardEvent];
}>();
const slots = useSlots();
// Stable, per-instance id so the disclosure trigger can `aria-controls` its region.
const expandRegionId = `settings-row-expand-${useId()}`;
// `defineModel` keeps the row working uncontrolled (built-in chevron) while still honouring a
// bound `v-model` or an `#action` control that drives the state.
const expanded = defineModel<boolean>({ default: false });
const isExpanded = computed(() => props.expandable && expanded.value);
// Mount the `#expanded` slot lazily: collapsed rows skip rendering their hidden (often heavy)
// content until first opened, then keep it mounted so the collapse animation has content to clip.
const hasExpandedOnce = ref(isExpanded.value);
watch(isExpanded, (value) => {
if (value) hasExpandedOnce.value = true;
});
const disclosureLabel = computed(() =>
isExpanded.value ? props.collapseLabel : props.expandLabel,
);
function toggleExpanded(event: MouseEvent) {
event.stopPropagation();
expanded.value = !expanded.value;
}
const descriptionLines = computed(() => Math.min(Math.max(props.maxDescriptionLines, 1), 3));
// Reveal the full description in a tooltip only when it is actually clamped/truncated. The
// description text always lives in the DOM (line-clamp clips it visually only), so this is a
// purely visual convenience for sighted pointer/focus users and adds no accessibility regression.
const descriptionRef = ref<InstanceType<typeof N8nText>>();
const isDescriptionTruncated = ref(false);
function getDescriptionEl(): HTMLElement | null {
const el: unknown = descriptionRef.value?.$el;
return el instanceof HTMLElement ? el : null;
}
function measureDescriptionTruncation() {
const el = getDescriptionEl();
// Line-clamp keeps clientHeight fixed at N lines; overflowing copy makes scrollHeight exceed
// it. The 1px tolerance guards against sub-pixel rounding when the text fits exactly.
isDescriptionTruncated.value = el ? el.scrollHeight - el.clientHeight > 1 : false;
}
// Width changes alter wrapping → re-measure on resize. `useResizeObserver` follows the ref's
// element (covers mount and a description that becomes visible later) and cleans up on unmount.
useResizeObserver(descriptionRef, measureDescriptionTruncation);
// Resize alone doesn't cover everything: measure immediately when the element appears/changes
// and when new content or a new clamp keeps the same element (no resize event fires for those).
watch(
() => [getDescriptionEl(), props.description, descriptionLines.value],
measureDescriptionTruncation,
{ flush: 'post', immediate: true },
);
const showVisualSlot = computed(() => props.showVisual || Boolean(slots.visual));
const actionStyle = computed(() => {
if (props.layout !== 'horizontal' || props.actionMaxWidth === false) {
return undefined;
}
return { maxWidth: props.actionMaxWidth };
});
const interactiveAttrs = computed(() =>
props.clickable ? { role: 'button', tabindex: 0, 'aria-label': props.title || undefined } : {},
);
// A clickable row must not hijack activations meant for a nested interactive control (a button,
// link, or input placed in a slot): those would otherwise bubble to the row's handlers and fire
// the row click on top of the control's own action. Clicks on the row's non-interactive content
// (title, description, the presentational N8nSettingsRowConfigure) still activate the row.
function isFromNestedInteractive(event: Event): boolean {
const { target, currentTarget } = event;
if (!(target instanceof Element) || !(currentTarget instanceof Element)) {
return false;
}
const interactive = target.closest('button, a[href], input, select, textarea, [tabindex]');
return interactive !== null && interactive !== currentTarget;
}
function onActivate(event: MouseEvent) {
if (props.clickable && !isFromNestedInteractive(event)) {
emit('click', event);
}
}
function onKeydown(event: KeyboardEvent) {
if (!props.clickable) {
return;
}
// Key events only activate the row when the row itself is focused. When focus sits on a
// nested control, Enter/Space belongs to that control (and preventDefault would break it).
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
emit('click', event);
}
}
</script>
<template>
<div
:class="[
$style.row,
$style[layout],
{
[$style.hoverable]: hoverable,
[$style.clickable]: clickable,
},
]"
:data-layout="layout"
v-bind="interactiveAttrs"
@click="onActivate"
@keydown="onKeydown"
>
<template v-if="layout === 'custom'">
<slot />
</template>
<template v-else>
<div :class="$style.info">
<div v-if="showVisualSlot" :class="$style.visual" data-test-id="settings-row-visual">
<slot name="visual" />
</div>
<div :class="$style.text">
<slot name="info">
<N8nText
v-if="title"
:class="[$style.title, { [$style.truncate]: truncateTitle }]"
bold
size="medium"
color="text-dark"
>
{{ title }}
</N8nText>
<N8nTooltip
v-if="description"
:content="description"
:disabled="!isDescriptionTruncated"
placement="top"
>
<N8nText
ref="descriptionRef"
:class="$style.description"
:style="{ '--settings-row--description-lines': descriptionLines }"
size="small"
color="text-light"
>
{{ description }}
</N8nText>
</N8nTooltip>
</slot>
</div>
</div>
<div
v-if="slots.action"
:class="[
$style.action,
{
[$style.revealActions]: revealActionsOnHover,
[$style.actionFill]: actionFill && layout === 'horizontal',
},
]"
:style="actionStyle"
@click="revealActionsOnHover ? $event.stopPropagation() : undefined"
>
<slot name="action" />
</div>
<button
v-if="expandable && disclosure"
type="button"
:class="$style.disclosure"
:aria-expanded="isExpanded"
:aria-controls="expandRegionId"
:aria-label="title ? `Toggle ${title}` : 'Toggle details'"
@click="toggleExpanded"
>
<N8nText
v-if="disclosureLabel"
:class="$style.disclosureLabel"
size="small"
color="text-base"
>
{{ disclosureLabel }}
</N8nText>
<N8nIcon :class="$style.disclosureIcon" icon="chevron-down" />
</button>
</template>
<div
v-if="expandable"
:id="expandRegionId"
:class="$style.expandRegion"
:data-expanded="isExpanded"
role="region"
>
<div :class="$style.expandInner">
<div :class="$style.expandContent">
<slot v-if="hasExpandedOnce" name="expanded" />
</div>
</div>
</div>
<span
v-if="showDivider"
:class="$style.divider"
data-test-id="settings-row-divider"
aria-hidden="true"
/>
</div>
</template>
<style lang="scss" module>
@use '../../css/mixins/utils';
// The expand/collapse motion. No DS duration token equals 350ms (snappy=200, base=400) and the
// curve has no token either, so both live here as local constants per the motion spec.
$expand-duration: 350ms;
$expand-easing: cubic-bezier(0.32, 0.72, 0, 1);
.row {
position: relative;
display: flex;
flex-wrap: wrap;
width: 100%;
box-sizing: border-box;
}
.hoverable,
.clickable {
transition: background-color 0.1s ease-in-out;
}
.hoverable:hover,
.clickable:hover {
background-color: var(--background--hover);
}
.clickable {
cursor: pointer;
}
.clickable:active {
background-color: var(--background--active);
}
.clickable:focus-visible {
outline: var(--focus--border-width, 2px) solid var(--focus--border-color);
outline-offset: calc(-1 * var(--focus--border-width, 2px));
}
.horizontal {
flex-direction: row;
align-items: center;
justify-content: space-between;
// Column-gap only: a row-gap would push the wrapped expand region away from the header.
column-gap: var(--spacing--2xs);
min-height: var(--height--4xl);
padding-inline: var(--spacing--sm);
}
.vertical {
flex-direction: column;
gap: var(--spacing--4xs);
padding: 0 var(--spacing--sm) var(--spacing--sm);
}
.custom {
flex-direction: column;
gap: var(--spacing--4xs);
padding: var(--spacing--sm);
}
.info {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing--2xs);
flex: 1 0 0;
min-width: 0;
}
/*
* Pin the header line to the full row height so it never re-stretches when the (wrapping)
* expand region grows. The horizontal row is a `flex-wrap` container with a `min-height`; with
* the default `align-content: stretch` the lone header line is stretched to fill that min-height
* while collapsed. As the expand region wraps in and the total content crosses the min-height
* threshold, that stretch is released and the vertically-centered header content snaps up by ~1px
* at the start of the expand (and back down at the end of the collapse). Guaranteeing the header
* line is always the row height keeps the centered content perfectly still while preserving the
* resting vertical centering.
*/
.horizontal .info {
min-height: var(--height--4xl);
}
.visual {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing--xl);
height: var(--spacing--xl);
border: var(--border-width, 1px) solid var(--border-color--subtle);
border-radius: var(--radius--2xs);
overflow: clip;
color: var(--text-color--subtle);
}
.text {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
padding-block: var(--spacing--xs);
flex: 1 0 0;
min-width: 0;
}
.title {
&.truncate {
display: block;
@include utils.utils-ellipsis;
}
}
.description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--settings-row--description-lines, 2);
overflow: hidden;
}
.action {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
/*
* "Fill": the action shares the row with the info (both grow), so it expands to its
* available width and is bounded by `actionMaxWidth`. Without this the action hugs its
* content (flex: 0 0 auto) and the cap only clamps intrinsically-wide content.
*/
.actionFill {
flex: 1 1 0;
}
.vertical .action {
width: 100%;
justify-content: flex-start;
}
.revealActions {
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
.row:hover .revealActions,
.row:focus-within .revealActions {
opacity: 1;
}
.disclosure {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--5xs);
margin-inline-start: var(--spacing--2xs);
padding: var(--spacing--4xs) var(--spacing--2xs);
border: none;
background: transparent;
border-radius: var(--radius);
color: var(--text-color--subtle);
cursor: pointer;
}
.disclosure:hover {
background: var(--background--hover);
}
.disclosure:focus-visible {
outline: var(--focus--border-width, 2px) solid var(--focus--border-color);
outline-offset: calc(-1 * var(--focus--border-width, 2px));
}
.disclosureLabel {
white-space: nowrap;
}
.disclosureIcon {
transition: transform $expand-duration $expand-easing;
}
.disclosure[aria-expanded='true'] .disclosureIcon {
transform: rotate(180deg);
}
/*
* Animated reveal: the grid 0fr→1fr technique animates real height without `height: auto`,
* paired with an opacity fade and a subtle blur. The region always takes a full row (it wraps
* below the header in the horizontal layout) and breaks out of the row's side padding so the
* revealed content reads as continuation rows of the same group, flush to the parent's edges.
*/
.expandRegion {
flex: 0 0 auto;
width: calc(100% + 2 * var(--spacing--sm));
margin-inline: calc(-1 * var(--spacing--sm));
box-sizing: border-box;
display: grid;
grid-template-rows: 0fr;
opacity: 0;
filter: blur(4px);
transition:
grid-template-rows $expand-duration $expand-easing,
opacity $expand-duration $expand-easing,
filter $expand-duration $expand-easing;
}
.expandRegion[data-expanded='true'] {
grid-template-rows: 1fr;
opacity: 1;
/* `none`, not `blur(0)`: a non-none filter would keep a stacking context (and typically a
* compositing surface) permanently active on every open row. `blur(4px) → none` still
* animates — the missing side interpolates as the identity filter. */
filter: none;
}
.expandInner {
min-height: 0;
overflow: hidden;
}
// Flush continuation rows: no indent/box, just an inset top separator (matching the group's
// row dividers) between the header and the first revealed row.
.expandContent {
position: relative;
}
.expandContent::before {
content: '';
position: absolute;
inset-block-start: 0;
inset-inline: var(--spacing--sm);
height: 1px;
background: var(--border-color--subtle);
}
@media (prefers-reduced-motion: reduce) {
.disclosureIcon {
transition: none;
}
// Drop the blur and the height animation; keep only a simple, quick fade.
.expandRegion {
filter: none;
transition: opacity $expand-duration linear;
}
}
.divider {
position: absolute;
bottom: 0;
left: var(--spacing--sm);
right: var(--spacing--sm);
height: 1px;
background: var(--border-color--subtle);
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsRow.vue';
export type { SettingsRowProps, SettingsRowLayout } from './SettingsRow.vue';
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nSettingsRowConfigure from './SettingsRowConfigure.vue';
const meta = {
title: 'Instance Settings/Settings Row Configure',
component: N8nSettingsRowConfigure,
argTypes: {
value: { control: 'text' },
},
parameters: {
docs: {
description: {
component:
'The always-visible affordance for a whole-row-clickable `N8nSettingsRow`: a single text plus a trailing right chevron. It shows "Configure" when not configured, or the configured-state free text once set up. Drop it into the row `#action` slot — it is presentational only, the parent row owns the click/keyboard behaviour.',
},
},
},
} satisfies Meta<typeof N8nSettingsRowConfigure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {},
};
export const Configured: Story = {
args: {
value: '2 of 3 devices',
},
};
@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/vue';
import N8nSettingsRowConfigure from './SettingsRowConfigure.vue';
describe('N8nSettingsRowConfigure', () => {
it('defaults to the "Configure" label with a trailing chevron (not a button)', () => {
const { container } = render(N8nSettingsRowConfigure);
const affordance = screen.getByTestId('settings-row-configure');
expect(affordance).toHaveTextContent('Configure');
expect(container.querySelector('button')).not.toBeInTheDocument();
expect(container.querySelector('[data-icon="chevron-right"]')).toBeInTheDocument();
});
it('replaces the label with the configured-state text when a value is provided', () => {
render(N8nSettingsRowConfigure, { props: { value: '2 of 3 devices' } });
const affordance = screen.getByTestId('settings-row-configure');
expect(affordance).toHaveTextContent('2 of 3 devices');
expect(affordance).not.toHaveTextContent('Configure');
});
it('always renders the trailing chevron', () => {
const { container } = render(N8nSettingsRowConfigure, { props: { value: 'Enabled' } });
expect(container.querySelector('[data-icon="chevron-right"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
<script setup lang="ts">
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
export interface SettingsRowConfigureProps {
/**
* The affordance text. Defaults to "Configure" (the not-configured state). Pass the
* configured-state free text (e.g. "2 of 3 devices", "Enabled", "n8n.example.com") to
* replace the label once the setting is configured. The trailing chevron is always shown.
*/
value?: string;
}
defineOptions({ name: 'N8nSettingsRowConfigure' });
withDefaults(defineProps<SettingsRowConfigureProps>(), {
value: 'Configure',
});
</script>
<template>
<span :class="$style.configure" data-test-id="settings-row-configure">
<N8nText :class="$style.value" size="small" color="text-dark">{{ value }}</N8nText>
<N8nIcon :class="$style.chevron" icon="chevron-right" size="small" />
</span>
</template>
<style lang="scss" module>
@use '../../css/mixins/utils';
.configure {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
min-width: 0;
}
.value {
min-width: 0;
@include utils.utils-ellipsis;
}
.chevron {
flex: 0 0 auto;
color: var(--icon-color);
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsRowConfigure.vue';
export type { SettingsRowConfigureProps } from './SettingsRowConfigure.vue';
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import N8nSettingsRowGroup from './SettingsRowGroup.vue';
import N8nButton from '../N8nButton';
import N8nInput from '../N8nInput';
import N8nSettingsRow from '../N8nSettingsRow';
import N8nSwitch from '../N8nSwitch';
const meta = {
title: 'Instance Settings/Settings Row Group',
component: N8nSettingsRowGroup,
parameters: {
docs: {
description: {
component:
'A bordered, rounded card that stacks settings rows. The last row hides its divider automatically; individual rows can opt out to merge into a sub-section.',
},
},
},
} satisfies Meta<typeof N8nSettingsRowGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
const frame = (inner: string) => `<div style="max-width: 45rem;">${inner}</div>`;
export const Default: Story = {
render: () => ({
components: { N8nSettingsRowGroup, N8nSettingsRow, N8nSwitch },
setup() {
const a = ref(true);
const b = ref(false);
const c = ref(true);
return { a, b, c };
},
template: frame(`
<N8nSettingsRowGroup>
<N8nSettingsRow title="Telemetry" description="Share anonymous usage data.">
<template #action><N8nSwitch v-model="a" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Beta features" description="Opt in to early features.">
<template #action><N8nSwitch v-model="b" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Email notifications" description="Last row hides its divider automatically.">
<template #action><N8nSwitch v-model="c" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
`),
}),
};
export const MixedLayouts: Story = {
render: () => ({
components: { N8nSettingsRowGroup, N8nSettingsRow, N8nSwitch, N8nButton, N8nInput },
setup() {
const enabled = ref(true);
return { enabled };
},
template: frame(`
<N8nSettingsRowGroup>
<N8nSettingsRow title="Telemetry" description="Horizontal row with a switch.">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Webhook URL" description="Vertical row with a wide input." layout="vertical">
<template #action><N8nInput placeholder="https://example.com/webhook" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Password" description="Horizontal row with a button.">
<template #action><N8nButton variant="outline" size="small" label="Change password" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
`),
}),
};
export const MergedSubsection: Story = {
render: () => ({
components: { N8nSettingsRowGroup, N8nSettingsRow, N8nButton },
template: frame(`
<N8nSettingsRowGroup>
<N8nSettingsRow title="2 other active sessions" :show-divider="false">
<template #action><N8nButton variant="outline" size="small" label="Revoke all" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Safari on iPhone" description="Gdynia, Poland · last seen 4 hours ago" :show-divider="false">
<template #action><N8nButton variant="outline" size="small" label="Revoke" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="n8n CLI" description="headless · last seen 3 days ago" />
</N8nSettingsRowGroup>
`),
}),
};
export const SingleRow: Story = {
render: () => ({
components: { N8nSettingsRowGroup, N8nSettingsRow, N8nButton },
template: frame(`
<N8nSettingsRowGroup>
<N8nSettingsRow title="Plan" description="Enterprise">
<template #action><N8nButton variant="outline" size="small" label="Manage plan" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
`),
}),
};
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/vue';
import { h } from 'vue';
import N8nSettingsRowGroup from './SettingsRowGroup.vue';
import N8nSettingsRow from '../N8nSettingsRow/SettingsRow.vue';
describe('N8nSettingsRowGroup', () => {
it('renders as a card container with its slotted rows', () => {
render(N8nSettingsRowGroup, {
slots: { default: '<div data-test-id="row">row</div>' },
});
expect(screen.getByTestId('settings-row-group')).toBeInTheDocument();
expect(screen.getByTestId('row')).toBeInTheDocument();
});
it('renders the requested tag', () => {
render(N8nSettingsRowGroup, {
props: { tag: 'section' },
slots: { default: 'content' },
});
expect(screen.getByTestId('settings-row-group').tagName).toBe('SECTION');
});
it('keeps a divider element on every row (last one is hidden via CSS)', () => {
const { container } = render({
components: { N8nSettingsRowGroup, N8nSettingsRow },
render() {
return h(N8nSettingsRowGroup, null, {
default: () => [
h(N8nSettingsRow, { title: 'One' }),
h(N8nSettingsRow, { title: 'Two' }),
h(N8nSettingsRow, { title: 'Three' }),
],
});
},
});
const dividers = container.querySelectorAll('[data-test-id="settings-row-divider"]');
expect(dividers).toHaveLength(3);
});
it('drops the divider of rows that opt out', () => {
const { container } = render({
components: { N8nSettingsRowGroup, N8nSettingsRow },
render() {
return h(N8nSettingsRowGroup, null, {
default: () => [
h(N8nSettingsRow, { title: 'One', showDivider: false }),
h(N8nSettingsRow, { title: 'Two' }),
],
});
},
});
const dividers = container.querySelectorAll('[data-test-id="settings-row-divider"]');
expect(dividers).toHaveLength(1);
});
});
@@ -0,0 +1,38 @@
<script setup lang="ts">
export interface SettingsRowGroupProps {
/** Element/component to render as the group container. */
tag?: string;
}
defineOptions({ name: 'N8nSettingsRowGroup' });
withDefaults(defineProps<SettingsRowGroupProps>(), {
tag: 'div',
});
</script>
<template>
<component :is="tag" :class="$style.group" data-test-id="settings-row-group">
<slot />
</component>
</template>
<style lang="scss" module>
.group {
display: flex;
flex-direction: column;
width: 100%;
border: var(--border-width, 1px) solid var(--border-color--subtle);
border-radius: var(--radius--xs);
background: var(--background--surface);
overflow: clip;
// Auto-hide the divider of the last row so consumers don't manage `showDivider`
// for the common case. Individual rows can still opt out with `show-divider="false"`.
// Scoped to the row's own (direct) divider so dividers nested inside a row's expanded
// region keep rendering as continuation rows.
> :last-child > [data-test-id='settings-row-divider'] {
display: none;
}
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsRowGroup.vue';
export type { SettingsRowGroupProps } from './SettingsRowGroup.vue';
@@ -0,0 +1,256 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { computed, ref } from 'vue';
import { confirmSaved } from './quickSaveNotification';
import N8nSettingsSaveBar from './SettingsSaveBar.vue';
import N8nInput from '../N8nInput';
import N8nSettingsRow from '../N8nSettingsRow';
import N8nSettingsRowGroup from '../N8nSettingsRowGroup';
import N8nSettingsSection from '../N8nSettingsSection';
import N8nSwitch from '../N8nSwitch';
const meta = {
title: 'Instance Settings/Settings Save Bar',
component: N8nSettingsSaveBar,
argTypes: {
visible: { control: 'boolean' },
message: { control: 'text' },
saveLabel: { control: 'text' },
discardLabel: { control: 'text' },
saving: { control: 'boolean' },
saveDisabled: { control: 'boolean' },
floating: { control: 'boolean' },
saveShortcut: { control: 'boolean' },
},
parameters: {
docs: {
description: {
component:
'The explicit-save affordance for high-impact instance settings. It stays hidden until there are unsaved changes, then slides up showing an "Unsaved changes" status on the left plus Discard (outline) and Save (solid) actions on the right — the primary action sits on the far right, consistent with dialogs. It is presentational: the consumer owns `visible` (bind it to a dirty flag), `saving`, and reacts to `save`/`discard`. On a successful save, hide the bar and confirm through the existing app notification (`useToast().showMessage` in the app). The bar is a gently rounded (12px) bordered rectangle with a prominent shadow that spans the 720px settings content column plus 12px on each side (744px), so it sits a touch proud of the column — set `floating` to stick it 24px above the bottom of that column while scrolling (render it as the last child of a `min-height: 100%` flex column inside the scroll container so it stays pinned at the viewport bottom on short pages too). Mirrors Figma 5991:7910.',
},
},
},
} satisfies Meta<typeof N8nSettingsSaveBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: {
visible: true,
message: 'Unsaved changes',
saveLabel: 'Save settings',
discardLabel: 'Discard changes',
saving: false,
saveDisabled: false,
floating: false,
saveShortcut: true,
},
render: (args) => ({
components: { N8nSettingsSaveBar },
setup() {
return { args };
},
template: `
<div style="max-width: 48rem;">
<N8nSettingsSaveBar v-bind="args" @save="() => {}" @discard="() => {}" />
</div>
`,
}),
};
export const Saving: Story = {
...Playground,
args: { ...Playground.args, saving: true },
parameters: {
docs: {
description: { story: 'While a save is in flight the Save button shows its loading state.' },
},
},
};
export const Floating: Story = {
render: () => ({
components: {
N8nSettingsSaveBar,
N8nSettingsSection,
N8nSettingsRowGroup,
N8nSettingsRow,
N8nInput,
},
setup() {
const value = ref('');
return { value };
},
template: `
<div style="height: 22rem; overflow-y: auto; padding: var(--spacing--sm); box-sizing: border-box; background: var(--background--subtle); border-radius: var(--radius--md);">
<div style="min-height: 100%; box-sizing: border-box; max-width: 45rem; margin-inline: auto; display: flex; flex-direction: column; gap: var(--spacing--lg);">
<N8nSettingsSection title="Webhook" description="Scroll the panel — the save bar sticks to the bottom of the column.">
<N8nSettingsRowGroup>
<N8nSettingsRow v-for="n in 6" :key="n" :title="'Setting ' + n" description="A high-impact instance setting that requires an explicit save." :action-fill="true">
<template #action><N8nInput v-model="value" placeholder="Edit me" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSaveBar floating :visible="true" @save="() => {}" @discard="() => {}" />
</div>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'With `floating`, the bar is `position: sticky` at the bottom of its container, so it hovers over the settings column (not the full window width) while the content scrolls beneath it. The floating contract: the bar is the last child of a flex column with `min-height: 100%` inside the scroll container — the bar carries `margin-top: auto`, so on pages shorter than the scrollport it is still pushed down and pinned at the bottom with its usual gap.',
},
},
},
};
export const Interactive: Story = {
render: () => ({
components: {
N8nSettingsSaveBar,
N8nSettingsSection,
N8nSettingsRowGroup,
N8nSettingsRow,
N8nInput,
},
setup() {
const saved = ref('https://otel.observability.acme');
const draft = ref(saved.value);
const saving = ref(false);
const dirty = computed(() => draft.value !== saved.value);
const onSave = () => {
saving.value = true;
// Simulate a request; on success commit the draft, hide the bar, and confirm.
setTimeout(() => {
saved.value = draft.value;
saving.value = false;
confirmSaved('Settings saved');
}, 1000);
};
const onDiscard = () => {
draft.value = saved.value;
};
return { draft, saving, dirty, onSave, onDiscard };
},
template: `
<div style="max-width: 45rem; display: flex; flex-direction: column; gap: var(--spacing--lg);">
<N8nSettingsSection title="Collector connection" description="Edit the endpoint to reveal the save bar. Discard reverts it; Save confirms with the app notification.">
<N8nSettingsRowGroup>
<N8nSettingsRow title="OTLP endpoint" description="Where to send OTLP traces." :action-fill="true">
<template #action><N8nInput v-model="draft" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSaveBar
:visible="dirty"
:saving="saving"
@save="onSave"
@discard="onDiscard"
/>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
"The full explicit-save loop: editing the field flips a dirty flag that drives `visible`, so the bar slides up. Discard reverts the draft (hiding the bar); Save shows the loading state, then hides the bar and confirms through n8n's existing bottom-right notification. Cmd/Ctrl+S also saves while the bar is visible.",
},
},
},
};
export const SettingsFlow: Story = {
render: () => ({
components: {
N8nSettingsSaveBar,
N8nSettingsSection,
N8nSettingsRowGroup,
N8nSettingsRow,
N8nInput,
N8nSwitch,
},
setup() {
// Explicit-save (high-impact) fields.
const saved = ref({ name: 'Acme Production', timezone: 'Europe/Warsaw' });
const draft = ref({ ...saved.value });
const saving = ref(false);
const dirty = computed(
() =>
draft.value.name !== saved.value.name || draft.value.timezone !== saved.value.timezone,
);
// Instant-save (low-impact) toggle.
const telemetry = ref(true);
const onSave = () => {
saving.value = true;
setTimeout(() => {
saved.value = { ...draft.value };
saving.value = false;
confirmSaved('Settings saved');
}, 1000);
};
const onDiscard = () => {
draft.value = { ...saved.value };
};
const onToggleTelemetry = () => {
// Low-impact: persists immediately and confirms with the same app notification.
confirmSaved('Settings saved');
};
return { draft, saving, dirty, telemetry, onSave, onDiscard, onToggleTelemetry };
},
// Full-height flex-column page (the floating contract): even though this page is shorter
// than the viewport, the bar's `margin-top: auto` pushes it to the bottom of the scrollport,
// where its sticky offset pins it 24px above the viewport edge.
template: `
<div style="min-height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; padding: var(--spacing--lg); background: var(--background--subtle);">
<div style="width: 100%; max-width: 45rem; margin-inline: auto; display: flex; flex-direction: column; gap: var(--spacing--xl);">
<N8nSettingsSection title="Instance" description="High-impact fields require an explicit save.">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Instance name" description="Shown in the header and in emails." :action-fill="true">
<template #action><N8nInput v-model="draft.name" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Timezone" description="Used to schedule and display times." :action-fill="true">
<template #action><N8nInput v-model="draft.timezone" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection title="Privacy" description="Low-impact toggles save instantly.">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Share anonymous telemetry" description="Help us improve n8n. Saved as soon as you toggle it.">
<template #action>
<N8nSwitch v-model="telemetry" @update:model-value="onToggleTelemetry" />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
</div>
<N8nSettingsSaveBar
floating
:visible="dirty"
:saving="saving"
@save="onSave"
@discard="onDiscard"
/>
</div>
`,
}),
parameters: {
layout: 'fullscreen',
docs: {
description: {
story:
"A realistic settings page combining both save modes: the high-impact Instance fields drive the floating explicit-save bar, while the low-impact telemetry toggle saves instantly. Both confirm through the existing app notification. The page is deliberately shorter than the viewport to show that the floating bar still pins to the bottom of the screen with its 24px gap (the full-height flex-column wrapper plus the bar's auto top margin).",
},
},
},
};
@@ -0,0 +1,135 @@
import { fireEvent, render, screen } from '@testing-library/vue';
import N8nSettingsSaveBar from './SettingsSaveBar.vue';
describe('N8nSettingsSaveBar', () => {
it('matches snapshot', () => {
const { html } = render(N8nSettingsSaveBar, {
global: { stubs: ['N8nButton', 'N8nIcon', 'N8nText'] },
});
expect(html()).toMatchSnapshot();
});
it('renders the default status message and Save/Discard buttons when visible', () => {
render(N8nSettingsSaveBar);
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
expect(screen.getByText('Save settings')).toBeInTheDocument();
expect(screen.getByText('Discard changes')).toBeInTheDocument();
});
it('renders custom message and labels', () => {
render(N8nSettingsSaveBar, {
props: { message: 'You have changes', saveLabel: 'Save', discardLabel: 'Reset' },
});
expect(screen.getByText('You have changes')).toBeInTheDocument();
expect(screen.getByText('Save')).toBeInTheDocument();
expect(screen.getByText('Reset')).toBeInTheDocument();
});
it('does not render anything while hidden', () => {
render(N8nSettingsSaveBar, { props: { visible: false } });
expect(screen.queryByTestId('settings-save-bar')).not.toBeInTheDocument();
});
it('emits save when the Save button is clicked', async () => {
const { emitted } = render(N8nSettingsSaveBar);
await fireEvent.click(screen.getByTestId('settings-save-bar-save'));
expect(emitted().save).toHaveLength(1);
});
it('emits discard when the Discard button is clicked', async () => {
const { emitted } = render(N8nSettingsSaveBar);
await fireEvent.click(screen.getByTestId('settings-save-bar-discard'));
expect(emitted().discard).toHaveLength(1);
});
it('puts the Save button in its loading state while saving', () => {
render(N8nSettingsSaveBar, { props: { saving: true } });
const save = screen.getByTestId('settings-save-bar-save');
expect(save).toHaveAttribute('aria-busy', 'true');
expect(save).toBeDisabled();
});
it('disables the Discard button while saving', () => {
render(N8nSettingsSaveBar, { props: { saving: true } });
expect(screen.getByTestId('settings-save-bar-discard')).toBeDisabled();
});
it('disables only the Save button when saveDisabled is set', () => {
render(N8nSettingsSaveBar, { props: { saveDisabled: true } });
expect(screen.getByTestId('settings-save-bar-save')).toBeDisabled();
expect(screen.getByTestId('settings-save-bar-discard')).not.toBeDisabled();
});
it('exposes the message as the region accessible name', () => {
render(N8nSettingsSaveBar, { props: { message: 'Unsaved changes' } });
const region = screen.getByRole('region', { name: 'Unsaved changes' });
expect(region).toHaveAttribute('aria-live', 'polite');
});
it('renders the primary Save action last so it sits on the far right', () => {
render(N8nSettingsSaveBar);
const discard = screen.getByTestId('settings-save-bar-discard');
const save = screen.getByTestId('settings-save-bar-save');
// DOM order matches visual order: Discard before Save (primary on the far right).
expect(discard.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('applies the floating class when floating', () => {
render(N8nSettingsSaveBar, { props: { floating: true } });
expect(screen.getByTestId('settings-save-bar').className).toContain('floating');
});
it('saves on Cmd/Ctrl+S while visible and enabled', () => {
const { emitted } = render(N8nSettingsSaveBar);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }),
);
expect(emitted().save).toHaveLength(1);
});
it('ignores Cmd/Ctrl+S while saving', () => {
const { emitted } = render(N8nSettingsSaveBar, { props: { saving: true } });
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }),
);
expect(emitted().save).toBeUndefined();
});
it('does not bind the save shortcut when saveShortcut is false', () => {
const { emitted } = render(N8nSettingsSaveBar, { props: { saveShortcut: false } });
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }),
);
expect(emitted().save).toBeUndefined();
});
it('renders custom status content through the default slot', () => {
render(N8nSettingsSaveBar, {
slots: { default: '<span data-test-id="custom-status">Draft saved locally</span>' },
});
expect(screen.getByTestId('custom-status')).toBeInTheDocument();
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
export interface SettingsSaveBarProps {
/** Controls show/hide. Animates a slide-up on appear and a slide-down on disappear. */
visible?: boolean;
/** Status message shown next to the warning icon. */
message?: string;
/** Primary button label. */
saveLabel?: string;
/** Secondary button label. */
discardLabel?: string;
/** Puts the Save button in its loading state while a save is in flight. */
saving?: boolean;
/** Disables the Save button (e.g. when the form is invalid). */
saveDisabled?: boolean;
/**
* Sticks the bar to the bottom of the scrollport so it floats over the settings column.
* Contract: render the bar as the last child of a flex-column wrapper with `min-height: 100%`
* inside the scroll container (the sticky-footer pattern). The bar carries `margin-top: auto`,
* so on short pages it is pushed to the wrapper bottom, where `position: sticky` lifts it to
* its usual 24px viewport gap; on long pages the auto margin collapses to zero and the bar
* floats over the scrolling content exactly as before.
*/
floating?: boolean;
/** Allow Cmd/Ctrl+S to trigger a save while the bar is visible and enabled. */
saveShortcut?: boolean;
}
defineOptions({ name: 'N8nSettingsSaveBar' });
const props = withDefaults(defineProps<SettingsSaveBarProps>(), {
visible: true,
message: 'Unsaved changes',
saveLabel: 'Save settings',
discardLabel: 'Discard changes',
saving: false,
saveDisabled: false,
floating: false,
saveShortcut: true,
});
const emit = defineEmits<{ save: []; discard: [] }>();
// Cmd/Ctrl+S submits the same way the Save button does. Guarded so it never fires while
// hidden, saving, or disabled. `useEventListener` auto-detaches on unmount.
function onKeydown(event: KeyboardEvent) {
if (!props.saveShortcut || !props.visible || props.saving || props.saveDisabled) return;
const isSaveCombo = (event.metaKey || event.ctrlKey) && (event.key === 's' || event.key === 'S');
if (!isSaveCombo) return;
event.preventDefault();
emit('save');
}
useEventListener(window, 'keydown', onKeydown);
</script>
<template>
<Transition name="n8n-settings-save-bar">
<div
v-if="visible"
:class="[$style.bar, { [$style.floating]: floating }]"
role="region"
:aria-label="message"
aria-live="polite"
data-test-id="settings-save-bar"
>
<div :class="$style.status" data-test-id="settings-save-bar-status">
<slot>
<span :class="$style.statusIcon" aria-hidden="true">
<N8nIcon icon="triangle-alert" size="medium" />
</span>
<N8nText size="medium" color="text-dark">{{ message }}</N8nText>
</slot>
</div>
<div :class="$style.actions">
<slot name="actions">
<N8nButton
variant="outline"
:label="discardLabel"
:disabled="saving"
data-test-id="settings-save-bar-discard"
@click="emit('discard')"
/>
<N8nButton
variant="solid"
:label="saveLabel"
:loading="saving"
:disabled="saveDisabled"
data-test-id="settings-save-bar-save"
@click="emit('save')"
/>
</slot>
</div>
</div>
</Transition>
</template>
<style lang="scss" module>
/*
* Reuse the expandable settings row's reveal motion (no DS token equals 350ms and the curve
* has no token either, so they live here as local constants, mirroring N8nSettingsRow).
*/
$slide-duration: 350ms;
$slide-easing: cubic-bezier(0.32, 0.72, 0, 1);
.bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
/*
* Bar width = 2 * side padding + the settings row width (--settings-content--max-width):
* 2*12px + 720px = 744px (Figma 5991:7910). Both terms reference their tokens — the same
* --n8n-settings-save-bar--padding-inline is used by `padding` and `width`, so the bar
* outgrows the column by exactly its own padding (inner edges sit on the column edges, give
* or take the 1px border under border-box sizing). Falls back to 45rem when the
* component-scoped --settings-content--max-width isn't in scope (e.g. when the floating bar is
* a sibling of N8nSettingsLayout rather than a descendant; mirrors N8nSettingsPageHeader).
* `max-width: 100%` keeps it from overflowing narrower containers.
*
* `margin-inline: auto` is what centers the bar within its container. It is `!important` so a
* higher-specificity host `margin` reset can't pin the bar to the left: e.g. Storybook's
* `#storybook-root > * { margin: ... }` (specificity 1,1,1) would otherwise beat this class
* (0,1,0) and collapse the auto margins to a fixed value, left-aligning the bar.
*/
--n8n-settings-save-bar--padding-inline: var(--spacing--xs);
width: calc(
var(--settings-content--max-width, 45rem) + 2 * var(--n8n-settings-save-bar--padding-inline)
);
max-width: 100%;
margin-inline: auto !important;
box-sizing: border-box;
padding: var(--spacing--xs) var(--n8n-settings-save-bar--padding-inline);
background: var(--background--surface);
border: var(--border-width, 1px) solid var(--border-color--subtle);
/*
* Gently rounded rectangle, not a pill: radius--sm (12px) in the DS3 scale, per design
* feedback. Hardcoded because the legacy compat layer (_tokens.legacy.scss) still overrides
* --radius--sm to 2px at :root for old --border-radius-small consumers, so the token can't be
* used directly yet. Switch to var(--radius--sm) once that legacy override is removed.
*/
border-radius: 0.75rem; /* 12px */
box-shadow: var(--shadow--xl);
}
.floating {
position: sticky;
bottom: var(--spacing--lg);
z-index: 2;
/*
* Sticky-footer half of the floating contract (see the `floating` prop docs). Sticky alone
* only LIFTS the bar within its parent's box — on a page shorter than the scrollport the
* parent ends right after the content, so the bar would sit in flow instead of at the
* viewport bottom. Inside the required flex-column wrapper (min-height: 100%), the auto top
* margin absorbs the free space and pushes the bar to the wrapper bottom, where the sticky
* `bottom` offset yields the usual 24px gap. On long pages there is no free space (and in
* plain block layout `auto` computes to 0), so nothing changes. `!important` for the same
* reason as the `margin-inline: auto` above: host margin resets with higher specificity
* (e.g. Storybook's `#storybook-root > * { margin: ... }`) must not defeat the auto margin.
*/
margin-top: auto !important;
}
/*
* Status left, actions right — the primary Save sits on the far right, matching the dialog
* convention (confirm on the right, back/destructive further left). DOM order follows the
* visual order, so Tab reaches Discard first and Save last.
*/
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing--2xs);
flex: 0 0 auto;
}
.status {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing--3xs);
min-width: 0;
}
.statusIcon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-color);
}
/* Slide-up + fade-in on appear, slide-down + fade-out on disappear. */
:global(.n8n-settings-save-bar-enter-active),
:global(.n8n-settings-save-bar-leave-active) {
transition:
opacity $slide-duration $slide-easing,
transform $slide-duration $slide-easing;
will-change: opacity, transform;
@media (prefers-reduced-motion: reduce) {
transition: opacity $slide-duration linear;
will-change: auto;
}
}
:global(.n8n-settings-save-bar-enter-from),
:global(.n8n-settings-save-bar-leave-to) {
opacity: 0;
transform: translateY(var(--spacing--xl, 2rem));
@media (prefers-reduced-motion: reduce) {
transform: none;
}
}
</style>
@@ -0,0 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`N8nSettingsSaveBar > matches snapshot 1`] = `
"<transition-stub name="n8n-settings-save-bar" appear="false" persisted="false" css="true">
<div class="bar" role="region" aria-label="Unsaved changes" aria-live="polite" data-test-id="settings-save-bar">
<div class="status" data-test-id="settings-save-bar-status"><span class="statusIcon" aria-hidden="true"><n8n-icon-stub icon="triangle-alert" size="medium" spin="false"></n8n-icon-stub></span>
<n8n-text-stub color="text-dark" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
</div>
<div class="actions">
<n8n-button-stub label="Discard changes" variant="outline" size="medium" loading="false" icononly="false" disabled="false" class="" data-test-id="settings-save-bar-discard"></n8n-button-stub>
<n8n-button-stub label="Save settings" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" data-test-id="settings-save-bar-save"></n8n-button-stub>
</div>
</div>
</transition-stub>"
`;
@@ -0,0 +1,2 @@
export { default } from './SettingsSaveBar.vue';
export type { SettingsSaveBarProps } from './SettingsSaveBar.vue';
@@ -0,0 +1,19 @@
import { ElNotification, type NotificationHandle } from 'element-plus';
// Storybook-only support for the settings stories (imported by *.stories.ts files only, not part
// of the library build). Save confirmations reuse n8n's existing app-wide notification — the
// bottom-right Element Plus notification themed by the design system's `notification.scss` (the
// same component `useToast().showMessage` shows in the app) — instead of introducing a new toast
// pattern. The stories call it exactly the way the app does on a successful save.
//
// Each quick-save confirmation REPLACES the previous one instead of stacking: rapid instant saves
// (e.g. flipping a toggle back and forth) would otherwise pile up notifications and eat vertical
// space. The handle is module-scoped so the behavior holds across every story that imports this
// helper, and only the last quick-save toast is closed — unrelated notifications are left alone.
let lastQuickSaveNotification: NotificationHandle | undefined;
export const confirmSaved = (title: string): NotificationHandle => {
lastQuickSaveNotification?.close();
lastQuickSaveNotification = ElNotification({ title, type: 'success', position: 'bottom-right' });
return lastQuickSaveNotification;
};
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import N8nSettingsSection from './SettingsSection.vue';
import N8nButton from '../N8nButton';
import N8nSettingsRow from '../N8nSettingsRow';
import N8nSettingsRowGroup from '../N8nSettingsRowGroup';
import N8nSwitch from '../N8nSwitch';
const meta = {
title: 'Instance Settings/Settings Section',
component: N8nSettingsSection,
parameters: {
docs: {
description: {
component:
'An optionally titled section that wraps one or more row groups. The vertical rhythm is fixed by design and driven by spacing tokens — there are no spacing props to override: the section header (title/description) sits 16px (`--spacing--sm`) above its body, separate row groups within the section are 12px (`--spacing--xs`) apart, and adjacent sibling sections are 32px (`--spacing--xl`) apart.',
},
},
},
} satisfies Meta<typeof N8nSettingsSection>;
export default meta;
type Story = StoryObj<typeof meta>;
const frame = (inner: string) => `<div style="max-width: 45rem;">${inner}</div>`;
export const WithTitleAndDescription: Story = {
render: (args) => ({
components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nSwitch },
setup() {
const enabled = ref(true);
return { args, enabled };
},
template: frame(`
<N8nSettingsSection v-bind="args">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Authenticator app" description="Six-digit codes from an app on your phone.">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
<N8nSettingsRow title="Security key" description="A small physical key you plug in or tap.">
<template #action><N8nSwitch v-model="enabled" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
`),
}),
args: {
title: 'Second factors',
description: "Required by your admin. After your password, you'll be asked for one of these.",
},
};
export const TitleOnly: Story = {
render: (args) => ({
components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nButton },
setup: () => ({ args }),
template: frame(`
<N8nSettingsSection v-bind="args">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Current version" description="2.9.4" />
</N8nSettingsRowGroup>
</N8nSettingsSection>
`),
}),
args: {
title: 'Version and updates',
},
};
export const NoHeader: Story = {
render: () => ({
components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nButton },
template: frame(`
<N8nSettingsSection>
<N8nSettingsRowGroup>
<N8nSettingsRow title="Resources and support" description="Links to docs and support.">
<template #action><N8nButton variant="outline" size="small" label="View" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
`),
}),
};
export const MultipleGroups: Story = {
render: (args) => ({
components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nButton },
setup: () => ({ args }),
template: frame(`
<N8nSettingsSection v-bind="args">
<N8nSettingsRowGroup>
<N8nSettingsRow title="Current version" description="2.9.4" />
</N8nSettingsRowGroup>
<N8nSettingsRowGroup>
<N8nSettingsRow title="Updates" description="2.10.2 available · 3 versions behind">
<template #action><N8nButton variant="outline" size="small" label="Update" /></template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
`),
}),
args: {
title: 'Version and updates',
},
};
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/vue';
import N8nSettingsSection from './SettingsSection.vue';
describe('N8nSettingsSection', () => {
it('renders the title and description', () => {
render(N8nSettingsSection, {
props: { title: 'Security', description: 'Manage security' },
slots: { default: '<div data-test-id="group">group</div>' },
});
expect(screen.getByText('Security')).toBeInTheDocument();
expect(screen.getByText('Manage security')).toBeInTheDocument();
expect(screen.getByTestId('group')).toBeInTheDocument();
});
it('renders the title with the requested heading tag', () => {
render(N8nSettingsSection, {
props: { title: 'Security', headingTag: 'h3' },
});
expect(screen.getByText('Security').tagName).toBe('H3');
});
it('omits the header when neither title nor description is set', () => {
const { container } = render(N8nSettingsSection, {
slots: { default: '<div>group</div>' },
});
expect(container.querySelector('[class*="header"]')).not.toBeInTheDocument();
});
it('renders the header when only a description is provided', () => {
const { container } = render(N8nSettingsSection, {
props: { description: 'Only a description' },
});
expect(container.querySelector('[class*="header"]')).toBeInTheDocument();
expect(screen.getByText('Only a description')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText';
export interface SettingsSectionProps {
/** Optional section title. */
title?: string;
/** Optional section description. */
description?: string;
/** Heading element for the section title. */
headingTag?: string;
}
defineOptions({ name: 'N8nSettingsSection' });
const props = withDefaults(defineProps<SettingsSectionProps>(), {
title: undefined,
description: undefined,
headingTag: 'h2',
});
const slots = useSlots();
const hasHeader = computed(() =>
Boolean(props.title || props.description || slots.title || slots.description),
);
</script>
<template>
<section :class="$style.section">
<div v-if="hasHeader" :class="$style.header">
<slot name="title">
<N8nHeading v-if="title" :tag="headingTag" step="md" color="text-dark">
{{ title }}
</N8nHeading>
</slot>
<slot name="description">
<N8nText v-if="description" size="small" color="text-base">
{{ description }}
</N8nText>
</slot>
</div>
<div :class="$style.groups">
<slot />
</div>
</section>
</template>
<style lang="scss" module>
.section {
display: flex;
flex-direction: column;
/* Enforced section-header (title/description) → body gap: 16px. */
gap: var(--spacing--sm);
width: 100%;
}
/*
* Sections own the enforced vertical separation from a preceding sibling section (32px).
* Higher specificity than the layout's generic inter-child spacing, so adjacent
* sections sit 32px apart while the header→content gap stays untouched.
*/
.section + .section {
margin-block-start: var(--spacing--xl); /* 32px */
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
}
.groups {
display: flex;
flex-direction: column;
/*
* Enforced gap between separate row GROUPS within a section: 12px. (Rows WITHIN a single
* group are separated by dividers in N8nSettingsRowGroup, not by this gap.)
*/
gap: var(--spacing--xs); /* 12px */
}
</style>
@@ -0,0 +1,2 @@
export { default } from './SettingsSection.vue';
export type { SettingsSectionProps } from './SettingsSection.vue';
@@ -76,6 +76,20 @@ export { default as N8nOption } from './N8nOption';
export { default as N8nPagination } from './N8nPagination';
export { default as N8nSectionHeader } from './N8nSectionHeader';
export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nSettingsLayout } from './N8nSettingsLayout';
export type { SettingsLayoutProps } from './N8nSettingsLayout';
export { default as N8nSettingsPageHeader } from './N8nSettingsPageHeader';
export type { SettingsPageHeaderProps } from './N8nSettingsPageHeader';
export { default as N8nSettingsRow } from './N8nSettingsRow';
export type { SettingsRowProps, SettingsRowLayout } from './N8nSettingsRow';
export { default as N8nSettingsRowConfigure } from './N8nSettingsRowConfigure';
export type { SettingsRowConfigureProps } from './N8nSettingsRowConfigure';
export { default as N8nSettingsRowGroup } from './N8nSettingsRowGroup';
export type { SettingsRowGroupProps } from './N8nSettingsRowGroup';
export { default as N8nSettingsSaveBar } from './N8nSettingsSaveBar';
export type { SettingsSaveBarProps } from './N8nSettingsSaveBar';
export { default as N8nSettingsSection } from './N8nSettingsSection';
export type { SettingsSectionProps } from './N8nSettingsSection';
export { default as N8nPreviewTag } from './PreviewTag/PreviewTag.vue';
export { default as N8nActionPill } from './N8nActionPill/ActionPill.vue';
export { default as N8nPopover } from './N8nPopover';
@@ -83,6 +83,7 @@ export const parameters = {
'Docs',
'Styleguide',
'Core',
'Instance Settings',
'Assistant',
'Chat',
'Tables',
@@ -38,3 +38,13 @@ body {
.sbdocs .sbdocs-content th:not(.sbdocs-preview th, .docs-story th) {
font-weight: var(--font-weight--bold);
}
/*
* The prose-table styling above is meant for Markdown/args tables in docs. Rendered component
* previews live inside `.sb-story` and bring their own table styling (e.g. N8nDataTableServer),
* so the docs prose block margin must not leak in — otherwise it pushes the header row down and
* leaves an empty gap at the top of the table card.
*/
.sbdocs .sbdocs-content .sb-story table {
margin-block: 0;
}