From b5fe23bedde403e3068a7148b82c7ceb17598cd5 Mon Sep 17 00:00:00 2001 From: Jan <3185243+JanOstrowka@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:59:48 +0200 Subject: [PATCH] feat(editor): Add instance settings components and Storybook examples (#32821) Co-authored-by: Cursor Co-authored-by: Ricardo Espinoza --- .../N8nSettingsLayout/Examples.stories.ts | 2142 +++++++++++++++++ .../SettingsLayout.stories.ts | 170 ++ .../N8nSettingsLayout/SettingsLayout.test.ts | 104 + .../N8nSettingsLayout/SettingsLayout.vue | 115 + .../src/components/N8nSettingsLayout/index.ts | 2 + .../SettingsPageHeader.stories.ts | 96 + .../SettingsPageHeader.test.ts | 113 + .../SettingsPageHeader.vue | 137 ++ .../components/N8nSettingsPageHeader/index.ts | 2 + .../N8nSettingsRow/SettingsRow.stories.ts | 468 ++++ .../N8nSettingsRow/SettingsRow.test.ts | 555 +++++ .../components/N8nSettingsRow/SettingsRow.vue | 565 +++++ .../src/components/N8nSettingsRow/index.ts | 2 + .../SettingsRowConfigure.stories.ts | 32 + .../SettingsRowConfigure.test.ts | 28 + .../SettingsRowConfigure.vue | 47 + .../N8nSettingsRowConfigure/index.ts | 2 + .../SettingsRowGroup.stories.ts | 104 + .../SettingsRowGroup.test.ts | 60 + .../N8nSettingsRowGroup/SettingsRowGroup.vue | 38 + .../components/N8nSettingsRowGroup/index.ts | 2 + .../SettingsSaveBar.stories.ts | 256 ++ .../SettingsSaveBar.test.ts | 135 ++ .../N8nSettingsSaveBar/SettingsSaveBar.vue | 221 ++ .../SettingsSaveBar.test.ts.snap | 15 + .../components/N8nSettingsSaveBar/index.ts | 2 + .../quickSaveNotification.ts | 19 + .../SettingsSection.stories.ts | 106 + .../SettingsSection.test.ts | 41 + .../N8nSettingsSection/SettingsSection.vue | 84 + .../components/N8nSettingsSection/index.ts | 2 + .../design-system/src/components/index.ts | 14 + .../@n8n/storybook/.storybook/preview.ts | 1 + .../@n8n/storybook/.storybook/storybook.scss | 10 + 34 files changed, 5690 insertions(+) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/Examples.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/SettingsLayout.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/SettingsLayout.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/SettingsLayout.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/__snapshots__/SettingsSaveBar.test.ts.snap create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/quickSaveNotification.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/index.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/Examples.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/Examples.stories.ts new file mode 100644 index 00000000000..1697b930d8f --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/Examples.stories.ts @@ -0,0 +1,2142 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { + computed, + defineComponent, + markRaw, + onBeforeUnmount, + onMounted, + ref, + useId, + type Component, + type PropType, +} from 'vue'; + +import N8nSettingsLayout from './SettingsLayout.vue'; +import N8nButton from '../N8nButton'; +import N8nDataTableServer from '../N8nDataTableServer'; +import { + N8nDialog, + N8nDialogClose, + N8nDialogDescription, + N8nDialogFooter, + N8nDialogHeader, + N8nDialogTitle, +} from '../N8nDialog'; +import { N8nDropdownMenu } from '../N8nDropdownMenu'; +import N8nIcon from '../N8nIcon'; +import type { IconName } from '../N8nIcon/icons'; +import N8nInput from '../N8nInput'; +import N8nOption from '../N8nOption'; +import N8nSelect from '../N8nSelect'; +import N8nSettingsPageHeader from '../N8nSettingsPageHeader'; +import N8nSettingsRow from '../N8nSettingsRow'; +import N8nSettingsRowConfigure from '../N8nSettingsRowConfigure'; +import N8nSettingsRowGroup from '../N8nSettingsRowGroup'; +import N8nSettingsSaveBar from '../N8nSettingsSaveBar'; +import { confirmSaved } from '../N8nSettingsSaveBar/quickSaveNotification'; +import N8nSettingsSection from '../N8nSettingsSection'; +import N8nSwitch from '../N8nSwitch'; +import N8nText from '../N8nText'; + +const meta = { + title: 'Instance Settings/Examples', + component: N8nSettingsLayout, + parameters: { + docs: { + description: { + component: + 'Composed examples mirroring the Figma examples frame: a Security & login page (leading visual slot + merged sub-section), a This instance page (metrics custom row + back action), and an API keys page (full-width table beneath a header that stays centered in the 720px column). The **Example Settings Page** wires the floating `N8nSettingsSaveBar` to a realistic dirty-state flow — editing a high-impact field slides the bar up, Discard reverts and Save confirms through the existing app notification (the bottom-right `ElNotification` that `useToast()` shows in the app), while a low-impact toggle saves instantly. The **Model Context Protocol** story re-expresses the instance-level MCP page in the native settings system: it enables/disables the server through a single **MCP status** status-action control (shown only while enabled: a green pulsing-dot "Enabled" dropdown whose danger "Disable" item opens an `N8nDialog` confirmation) rather than a toggle — collapsing to a dashed-border empty state, the sole enable affordance, while disabled — folds the **Connection details** inline as a client-led flow (a searchable, three-category "Client" picker that drives a dividerless group of official `N8nSettingsRow`s whose copyable values use `CopyInput` (readonly `N8nInput` + copy button): CLI Install/Configure/Authenticate, a web-client one-click "Add to …" row, or IDE deep-link + Server URL/token/Configure rows), summarizes **Access** ("4 of 7 allowed" Permissions, "12 across 4 projects" Workflows available — the dedicated sub-pages behind those rows are not part of this story set), and previews **Connected clients** inline. Access is granted **per connected client** (like PostHog\'s "Connected applications"): each preview row renders its grant as muted **plain truncated text** ("List workflows, Get workflow details +5" — never chips) that opens a **client details dialog** (brand mark + name, Connected by / Connected on / Last active, the full grant grouped by tool type, and a destructive Revoke access).', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// One-shot check used by the animated prototypes to skip decorative motion. Deliberately not +// reactive: each animation reads it when it starts, matching how the CSS media query gates the +// equivalent declarative transitions. +const prefersReducedMotion = () => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +// The design system ships no per-IDE brand icons, so the official brand marks are inlined here as +// tiny presentational components, mirroring the same `1em`-sized SVG pattern so each tracks the +// adjacent menu label / `N8nIcon`. They are used nominatively to identify "connect to " +// affordances (the Connect menu, the Connected clients table/preview). Each keeps its source +// `viewBox` so the aspect ratio is never distorted; pass a font-size token, e.g. +// `var(--font-size--md)`, to size them. +// +// Cursor — rendered in the brand near-black (`#26251e`) like the original mark (no `currentColor`), +// so it reads as the Cursor logo everywhere it appears (the Connect menu, the main page preview, +// and the Connected clients table cell). Source SVG: the official Cursor logomark. +const CursorLogo: Component = { + name: 'CursorLogo', + template: ` + + `, +}; + +// Claude — the official Claude logomark in its brand coral (`#D97757`), the colorful version (not +// the monochrome one). Source path/viewBox: simple-icons `claude.svg`. +const ClaudeLogo: Component = { + name: 'ClaudeLogo', + template: ` + + `, +}; + +// Codex — a faithful inline rendition of the actual Codex app icon (not the generic OpenAI +// blossom): the scalloped "blossom" silhouette filled with the app's purple→blue vertical gradient, +// with the white terminal-prompt glyph on top (a `>` chevron + an `_` underscore). Authored by hand +// as a small SVG — overlapping petal circles for the blossom plus two stroked glyph paths — so it +// stays crisp and token-sized like the other menu marks and carries its own colours (no +// `currentColor`). Used nominatively to mark the "connect to Codex" menu item. +const CodexLogo: Component = { + name: 'CodexLogo', + template: ` + + `, +}; + +// Gemini — Google's 2025 four-point concave "spark" mark, recreated as an inline sparkle path with a +// diagonal blue→purple→magenta gradient (`#4285F4` → `#9B72CB` → `#D96570`) approximating the official +// icon. Carries its own colours (no `currentColor`) and stays token-sized like the other menu marks. +// The gradient id is made unique per instance via Vue's `useId()` so multiple inlined copies never +// collide on a shared `url(#…)` reference. Used to mark the "Gemini CLI" client in the picker. +const GeminiLogo: Component = { + name: 'GeminiLogo', + setup() { + const gradientId = `gemini-spark-${useId()}`; + return { gradientId }; + }, + template: ` + + `, +}; + +// VS Code — the official multi-tone "blue ribbon" logomark (`#0065A9` / `#007ACC` / `#1F9CF0`) drawn +// through the ribbon `mask`; the outer drop-shadow filters from the source asset are dropped so it +// stays a clean, token-sized brand mark. Source path/viewBox: the official VS Code logo SVG. Because +// the mark appears in several places at once (the Connect menu, the Connected clients table), the +// mask id is made unique per instance via Vue's `useId()` so multiple inlined copies never collide on +// a shared `url(#…)` reference. +const VsCodeLogo: Component = { + name: 'VsCodeLogo', + setup() { + const maskId = `vscode-ribbon-${useId()}`; + return { maskId }; + }, + template: ` + + `, +}; + +// OpenAI — the official "blossom" logomark in brand black, used for any ChatGPT / OpenAI client. +// Source path/viewBox: simple-icons \`openai.svg\`. +const OpenAiLogo: Component = { + name: 'OpenAiLogo', + template: ` + + `, +}; + +// Maps a connected-client id to its inlined brand mark, used by the main-page preview and the +// Connect picker; anything unmapped falls back to the client's DS icon. `markRaw` keeps the +// component objects from being wrapped in a reactive proxy when handed to ``. +// `claude-ai` shares the Claude mark so a newly-authorized Claude.ai connection renders it too. +const clientLogoComponents = markRaw>({ + cursor: CursorLogo, + claude: ClaudeLogo, + 'claude-ai': ClaudeLogo, + codex: CodexLogo, + gemini: GeminiLogo, + vscode: VsCodeLogo, + chatgpt: OpenAiLogo, +}); + +// Animated client-logo cluster for the MCP empty states, mimicking the External Secrets empty +// state (n8n PR #24685): a fanned trio of small bordered cards — the outer two tilted ±8°, the +// centre one raised and on top — where the static centre card carries the feature's own DS icon +// (`icon` prop) and the two side cards cycle through the MCP client brand marks (Claude, VS Code, +// Codex, Cursor, Gemini, ChatGPT/OpenAI) with the PR's staggered fade+blur swap: every 3s the left +// card fades out (300ms opacity+blur), swaps to the next mark, and fades back in; the right card +// does the same 1.5s later. The right side starts halfway around the cycle so the two sides never +// show the same mark at once. Story render templates can't carry scoped CSS, so the card/transition +// rules are injected once into the head (guarded by a fixed id), mirroring the StatusDot injection. +// Honors `prefers-reduced-motion` by skipping the cycling entirely (two static marks, no fade). +// Purely decorative, so the whole cluster is `aria-hidden`. +const MCP_LOGO_CARDS_STYLE_ID = 'mcp-logo-cards-styles'; +const LOGO_CARDS_FADE_MS = 300; +const LOGO_CARDS_STAGGER_MS = 1500; +const LOGO_CARDS_CYCLE_MS = 3000; +// Cycling order for the side cards, interleaved so consecutive marks read clearly different +// (coral wordmark → blue ribbon → purple blossom → near-black → gradient spark → black blossom). +const logoCardsCycle = markRaw([ + ClaudeLogo, + VsCodeLogo, + CodexLogo, + CursorLogo, + GeminiLogo, + OpenAiLogo, +]); +const ClientLogoCards = defineComponent({ + name: 'ClientLogoCards', + components: { N8nIcon }, + props: { + // The static centre mark, naming the feature the empty state belongs to. + icon: { type: String as PropType, default: 'mcp' }, + }, + setup() { + if (typeof document !== 'undefined' && !document.getElementById(MCP_LOGO_CARDS_STYLE_ID)) { + const style = document.createElement('style'); + style.id = MCP_LOGO_CARDS_STYLE_ID; + style.textContent = + '.mcp-logo-cards { display: flex; align-items: center; justify-content: center; }' + + // The card's font-size sizes both the 1em brand marks and the (sizeless) centre N8nIcon. + '.mcp-logo-cards__card { display: flex; align-items: center; justify-content: center; flex: 0 0 auto; width: calc(var(--spacing--md) * 2); height: calc(var(--spacing--md) * 2); border: var(--border-width, 1px) solid var(--border-color--subtle); border-radius: var(--radius--xs); background: var(--background--surface); box-shadow: var(--shadow--xs); overflow: hidden; font-size: var(--font-size--xl); color: var(--text-color--subtle); }' + + '.mcp-logo-cards__card:nth-child(1) { transform: rotate(-8deg); }' + + '.mcp-logo-cards__card:nth-child(2) { z-index: 1; transform: translateY(calc(-1 * var(--spacing--4xs))); }' + + '.mcp-logo-cards__card:nth-child(3) { transform: rotate(8deg); }' + + `.mcp-logo-cards__logo { display: inline-flex; opacity: 1; filter: blur(0); transition: opacity ${LOGO_CARDS_FADE_MS}ms ease-in-out, filter ${LOGO_CARDS_FADE_MS}ms ease-in-out; }` + + '.mcp-logo-cards__logo--fading { opacity: 0; filter: blur(4px); }' + + '@media (prefers-reduced-motion: reduce) { .mcp-logo-cards__logo { transition: none; } }'; + document.head.appendChild(style); + } + + const leftIndex = ref(0); + const rightIndex = ref(Math.floor(logoCardsCycle.length / 2)); + const leftFading = ref(false); + const rightFading = ref(false); + + // Fade out → swap the mark mid-fade → fade back in (the swap timeout matches the CSS fade). + let cycleTimer = 0; + let staggerTimer = 0; + let leftSwapTimer = 0; + let rightSwapTimer = 0; + const swapLeft = () => { + leftFading.value = true; + leftSwapTimer = window.setTimeout(() => { + leftIndex.value = (leftIndex.value + 1) % logoCardsCycle.length; + leftFading.value = false; + }, LOGO_CARDS_FADE_MS); + }; + const swapRight = () => { + rightFading.value = true; + rightSwapTimer = window.setTimeout(() => { + rightIndex.value = (rightIndex.value + 1) % logoCardsCycle.length; + rightFading.value = false; + }, LOGO_CARDS_FADE_MS); + }; + + onMounted(() => { + if (prefersReducedMotion()) return; + cycleTimer = window.setInterval(() => { + swapLeft(); + staggerTimer = window.setTimeout(swapRight, LOGO_CARDS_STAGGER_MS); + }, LOGO_CARDS_CYCLE_MS); + }); + onBeforeUnmount(() => { + window.clearInterval(cycleTimer); + window.clearTimeout(staggerTimer); + window.clearTimeout(leftSwapTimer); + window.clearTimeout(rightSwapTimer); + }); + + return { logos: logoCardsCycle, leftIndex, rightIndex, leftFading, rightFading }; + }, + template: ` + + `, +}); + +// Small status dot used by the "Enable MCP access" control: solid when disabled (red), gently pulsing +// when enabled (green). Story `render()` templates can't carry a scoped ` diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/index.ts new file mode 100644 index 00000000000..3cc9e426793 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsLayout/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsLayout.vue'; +export type { SettingsLayoutProps } from './SettingsLayout.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.stories.ts new file mode 100644 index 00000000000..4b55600df0b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +// Wider than 720px to show the header capping itself at the content max-width. +const frame = (inner: string) => `
${inner}
`; + +export const Default: Story = { + render: (args) => ({ + components: { N8nSettingsPageHeader }, + setup: () => ({ args }), + template: frame(''), + }), + 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(''), + }), + 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(''), + }), + args: { + title: 'Members', + description: 'People with access to this instance.', + showDocsLink: false, + }, +}; + +export const TitleOnly: Story = { + render: (args) => ({ + components: { N8nSettingsPageHeader }, + setup: () => ({ args }), + template: frame(''), + }), + args: { + title: 'Members', + showDocsLink: false, + }, +}; + +export const LongDescription: Story = { + render: (args) => ({ + components: { N8nSettingsPageHeader }, + setup: () => ({ args }), + template: frame(''), + }), + 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', + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.test.ts new file mode 100644 index 00000000000..b729c41b422 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.vue new file mode 100644 index 00000000000..eda8004976c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/SettingsPageHeader.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/index.ts new file mode 100644 index 00000000000..226ffd486ca --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsPageHeader/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsPageHeader.vue'; +export type { SettingsPageHeaderProps } from './SettingsPageHeader.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.stories.ts new file mode 100644 index 00000000000..e4b604f8182 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +const card = (inner: string) => + `
${inner}
`; + +export const Horizontal: Story = { + render: (args) => ({ + components: { N8nSettingsRow, N8nSettingsRowGroup, N8nButton }, + setup: () => ({ args }), + template: card(` + + + + `), + }), + 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(` + + + + `), + }), + 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 = ` +
+
+
+ {{ metric.title }} + Last 7 days +
+
+ {{ metric.value }} + {{ metric.suffix }} + + + {{ metric.deltaText }} + +
+
+
+`; + +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(` + + + + + + + + + + `), + }), + 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(` + + + + + `), + }), + 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(` + + + + `), + }), + 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(` + + + + + + + `), + }), + 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(` + + + + + + + + + + + + + + + + + + + `), + }), + 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(` + + + + + + + `), + }), + 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(` + + + + + `), + }), + 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(` + + + + `), + }), + 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(` + + + + `), + }), + 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(` + + + + `), + }), + 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(` + + + + + + + `), + }), + 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(` + + + + + + + + + `), + }), + args: { + title: 'Chrome 138 on macOS', + description: 'Gdynia, Poland · active now. Hover (or focus) to reveal the action.', + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.test.ts new file mode 100644 index 00000000000..301b2ec7ce0 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.test.ts @@ -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: '
content
' }, + }); + + 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: '
content
' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: 'icon' }, + }); + + 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: '
custom
' }, + }); + + 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: '' }, + }); + + 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: '' }, + }); + + 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: '
details
' }, + }); + + 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: '
details
' }, + }); + + // 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: '' }, + }); + + 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: '' }, + }); + + 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: + '
', + }; + + 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) => { + 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; + 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(); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue new file mode 100644 index 00000000000..93ca0891f79 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue @@ -0,0 +1,565 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/index.ts new file mode 100644 index 00000000000..071666dbdf7 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsRow.vue'; +export type { SettingsRowProps, SettingsRowLayout } from './SettingsRow.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.stories.ts new file mode 100644 index 00000000000..fc1b6957c94 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Configured: Story = { + args: { + value: '2 of 3 devices', + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.test.ts new file mode 100644 index 00000000000..f1446b700e8 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.vue new file mode 100644 index 00000000000..6a11738d51f --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/SettingsRowConfigure.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/index.ts new file mode 100644 index 00000000000..b64c0564e7c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowConfigure/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsRowConfigure.vue'; +export type { SettingsRowConfigureProps } from './SettingsRowConfigure.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.stories.ts new file mode 100644 index 00000000000..3094417d516 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +const frame = (inner: string) => `
${inner}
`; + +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(` + + + + + + + + + + + + `), + }), +}; + +export const MixedLayouts: Story = { + render: () => ({ + components: { N8nSettingsRowGroup, N8nSettingsRow, N8nSwitch, N8nButton, N8nInput }, + setup() { + const enabled = ref(true); + return { enabled }; + }, + template: frame(` + + + + + + + + + + + + `), + }), +}; + +export const MergedSubsection: Story = { + render: () => ({ + components: { N8nSettingsRowGroup, N8nSettingsRow, N8nButton }, + template: frame(` + + + + + + + + + + `), + }), +}; + +export const SingleRow: Story = { + render: () => ({ + components: { N8nSettingsRowGroup, N8nSettingsRow, N8nButton }, + template: frame(` + + + + + + `), + }), +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.test.ts new file mode 100644 index 00000000000..5164dc5a414 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.test.ts @@ -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: '
row
' }, + }); + + 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); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.vue new file mode 100644 index 00000000000..67a49acae54 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/SettingsRowGroup.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/index.ts new file mode 100644 index 00000000000..3a34f8ae65a --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRowGroup/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsRowGroup.vue'; +export type { SettingsRowGroupProps } from './SettingsRowGroup.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.stories.ts new file mode 100644 index 00000000000..385d9ae043d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +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: ` +
+ +
+ `, + }), +}; + +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: ` +
+
+ + + + + + + + +
+
+ `, + }), + 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: ` +
+ + + + + + + + +
+ `, + }), + 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: ` +
+
+ + + + + + + + + + + + + + + + + + +
+ + +
+ `, + }), + 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).", + }, + }, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.test.ts new file mode 100644 index 00000000000..96d0e3842d5 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.test.ts @@ -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: 'Draft saved locally' }, + }); + + expect(screen.getByTestId('custom-status')).toBeInTheDocument(); + expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.vue new file mode 100644 index 00000000000..1718a0b96db --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/SettingsSaveBar.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/__snapshots__/SettingsSaveBar.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/__snapshots__/SettingsSaveBar.test.ts.snap new file mode 100644 index 00000000000..1e5a982cab8 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/__snapshots__/SettingsSaveBar.test.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8nSettingsSaveBar > matches snapshot 1`] = ` +" +
+
+ +
+
+ + +
+
+
" +`; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/index.ts new file mode 100644 index 00000000000..c5e165772d1 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsSaveBar.vue'; +export type { SettingsSaveBarProps } from './SettingsSaveBar.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/quickSaveNotification.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/quickSaveNotification.ts new file mode 100644 index 00000000000..d671a8e2fc1 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSaveBar/quickSaveNotification.ts @@ -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; +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.stories.ts new file mode 100644 index 00000000000..fef9ce2b11c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +const frame = (inner: string) => `
${inner}
`; + +export const WithTitleAndDescription: Story = { + render: (args) => ({ + components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nSwitch }, + setup() { + const enabled = ref(true); + return { args, enabled }; + }, + template: frame(` + + + + + + + + + + + `), + }), + 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(` + + + + + + `), + }), + args: { + title: 'Version and updates', + }, +}; + +export const NoHeader: Story = { + render: () => ({ + components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nButton }, + template: frame(` + + + + + + + + `), + }), +}; + +export const MultipleGroups: Story = { + render: (args) => ({ + components: { N8nSettingsSection, N8nSettingsRowGroup, N8nSettingsRow, N8nButton }, + setup: () => ({ args }), + template: frame(` + + + + + + + + + + + `), + }), + args: { + title: 'Version and updates', + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.test.ts new file mode 100644 index 00000000000..15caf608d41 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.test.ts @@ -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: '
group
' }, + }); + + 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: '
group
' }, + }); + + 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(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.vue new file mode 100644 index 00000000000..2c9cb26c08a --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/SettingsSection.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/index.ts new file mode 100644 index 00000000000..0affa4e1d32 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsSection/index.ts @@ -0,0 +1,2 @@ +export { default } from './SettingsSection.vue'; +export type { SettingsSectionProps } from './SettingsSection.vue'; diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index 35532e0f06c..c0e81709b3a 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/storybook/.storybook/preview.ts b/packages/frontend/@n8n/storybook/.storybook/preview.ts index d8ddcdfdbc6..0285933d2ca 100644 --- a/packages/frontend/@n8n/storybook/.storybook/preview.ts +++ b/packages/frontend/@n8n/storybook/.storybook/preview.ts @@ -83,6 +83,7 @@ export const parameters = { 'Docs', 'Styleguide', 'Core', + 'Instance Settings', 'Assistant', 'Chat', 'Tables', diff --git a/packages/frontend/@n8n/storybook/.storybook/storybook.scss b/packages/frontend/@n8n/storybook/.storybook/storybook.scss index e3afa150b2a..0bf63805169 100644 --- a/packages/frontend/@n8n/storybook/.storybook/storybook.scss +++ b/packages/frontend/@n8n/storybook/.storybook/storybook.scss @@ -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; +}