From cbbe113d253d9dd02cc68a2e166c94bf17b76ce1 Mon Sep 17 00:00:00 2001 From: Sebastien Powell Date: Fri, 3 Jul 2026 10:19:22 +0100 Subject: [PATCH] feat(editor): Add new Tree component to @n8n/design-system (#33291) --- .../frontend/@n8n/design-system/src/index.ts | 2 + .../src/v2/components/Tree/Tree.stories.ts | 791 ++++++++++++++++++ .../src/v2/components/Tree/Tree.test.ts | 265 ++++++ .../src/v2/components/Tree/Tree.types.ts | 94 +++ .../components/Tree/Tree.variables.module.css | 7 + .../src/v2/components/Tree/Tree.vue | 260 ++++++ .../src/v2/components/Tree/TreeNode.vue | 122 +++ .../Tree/TreeNodeDefault.stories.ts | 270 ++++++ .../v2/components/Tree/TreeNodeDefault.vue | 245 ++++++ .../src/v2/components/Tree/component-tree.md | 255 ++++-- .../src/v2/components/Tree/index.ts | 2 + 11 files changed, 2262 insertions(+), 51 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.types.ts create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.variables.module.css create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.vue create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNode.vue create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.vue create mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Tree/index.ts diff --git a/packages/frontend/@n8n/design-system/src/index.ts b/packages/frontend/@n8n/design-system/src/index.ts index 8dd0118e979..ac48770eba0 100644 --- a/packages/frontend/@n8n/design-system/src/index.ts +++ b/packages/frontend/@n8n/design-system/src/index.ts @@ -20,6 +20,8 @@ export { default as N8nLoading2 } from './v2/components/Loading/Loading.vue'; export type * from './v2/components/Loading/Loading.types'; export { default as N8nInputNumber2 } from './v2/components/InputNumber/InputNumber.vue'; export type * from './v2/components/InputNumber/InputNumber.types'; +export { default as N8nTree2 } from './v2/components/Tree/Tree.vue'; +export type * from './v2/components/Tree/Tree.types'; export { default as N8nSwitch2 } from './components/N8nSwitch/Switch.vue'; export type * from './components/N8nSwitch/Switch.types'; export { createPasswordRules } from './components/N8nFormInput/validators'; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.stories.ts b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.stories.ts new file mode 100644 index 00000000000..3ecc0aaa5bf --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.stories.ts @@ -0,0 +1,791 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { defineComponent, type PropType, ref } from 'vue'; + +import type { TreeBranch, TreeNodeContext } from './Tree.types'; +import Tree from './Tree.vue'; + +type GenericMeta = Omit, 'component'> & { + component: Record; +}; + +const menuItems: TreeBranch[] = [ + { + id: 'workflows', + label: 'Workflows', + icon: 'bolt-filled', + children: [ + { + id: 'all-workflows', + label: 'All workflows', + icon: 'list-tree', + children: [ + { + id: 'personal-workflows', + label: 'Personal', + icon: 'user', + children: [ + { + id: 'draft-workflows', + label: 'Drafts', + icon: 'file-text', + children: [ + { + id: 'recent-drafts', + label: 'Recent drafts', + icon: 'history', + children: [{ id: 'today-drafts', label: 'Edited today', icon: 'clock' }], + }, + { id: 'archived-drafts', label: 'Archived', icon: 'archive' }, + ], + }, + { id: 'published-workflows', label: 'Published', icon: 'check' }, + ], + }, + { id: 'shared-workflows', label: 'Shared with me' }, + { id: 'team-workflows', label: 'Team workflows', icon: 'users' }, + ], + }, + { id: 'workflow-templates', label: 'Templates', icon: 'layout-template' }, + { id: 'workflow-favorites', label: 'Favorites', icon: 'star' }, + ], + }, + { + id: 'credentials', + label: 'Credentials', + icon: 'lock', + children: [ + { id: 'all-credentials', label: 'All credentials', icon: 'key-round' }, + { id: 'shared-credentials', label: 'Shared credentials', icon: 'share' }, + ], + }, + { + id: 'executions', + label: 'Executions', + icon: 'list', + children: [ + { id: 'recent-executions', label: 'Recent', icon: 'play' }, + { id: 'failed-executions', label: 'Failed', icon: 'circle-x' }, + ], + }, + { + id: 'variables', + label: 'Variables', + icon: 'variable', + }, + { + id: 'settings', + label: 'Settings', + icon: 'settings', + children: [ + { id: 'users', label: 'Users', icon: 'user-round' }, + { id: 'api', label: 'API', icon: 'plug' }, + ], + }, +]; + +const defaultExpanded = [ + 'workflows', + 'all-workflows', + 'personal-workflows', + 'draft-workflows', + 'recent-drafts', + 'credentials', + 'executions', + 'settings', +]; + +const controlledDemoItems: TreeBranch[] = [ + { + id: 'workflows', + label: 'Workflows', + icon: 'bolt-filled', + children: [ + { id: 'all-workflows', label: 'All workflows', icon: 'list-tree' }, + { id: 'templates', label: 'Templates', icon: 'layout-template' }, + ], + }, + { + id: 'credentials', + label: 'Credentials', + icon: 'lock', + children: [{ id: 'all-credentials', label: 'All credentials', icon: 'key-round' }], + }, + { + id: 'variables', + label: 'Variables', + icon: 'variable', + }, +]; + +const meta = { + title: 'Experimental/Tree', + component: Tree, + tags: ['autodocs'], + argTypes: { + disabled: { + control: 'boolean', + description: 'Disable tree interaction', + }, + multiple: { + control: 'boolean', + description: 'Allow selecting multiple items', + }, + showExpandArrow: { + control: 'boolean', + description: 'Show chevron toggle for expandable items', + }, + virtualized: { + control: 'boolean', + description: 'Virtualize the flattened list for large trees', + }, + estimateSize: { + control: 'number', + description: 'Estimated row height in px when virtualized', + }, + }, +} satisfies GenericMeta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + components: { Tree }, + setup() { + const selected = ref([]); + const expanded = ref([...defaultExpanded]); + + return { args, selected, expanded, menuItems }; + }, + template: ` +
+ +
+ `, + }), + args: { + items: menuItems, + disabled: false, + multiple: false, + showExpandArrow: true, + }, +}; + +const VIRTUAL_TREE_BRANCHES = 5; +const VIRTUAL_TREE_CATEGORIES = 12; +const VIRTUAL_TREE_ITEMS = 10; + +function createNestedVirtualTree(): { + items: TreeBranch[]; + nodeCount: number; + defaultExpanded: string[]; +} { + let nodeCount = 0; + const defaultExpanded: string[] = []; + + const items = Array.from({ length: VIRTUAL_TREE_BRANCHES }, (_, branchIndex) => { + const branchId = `branch-${branchIndex}`; + defaultExpanded.push(branchId); + nodeCount++; + + return { + id: branchId, + label: `Department ${branchIndex + 1}`, + icon: 'list-tree' as const, + children: Array.from({ length: VIRTUAL_TREE_CATEGORIES }, (_, categoryIndex) => { + const categoryId = `${branchId}-category-${categoryIndex}`; + defaultExpanded.push(categoryId); + nodeCount++; + + return { + id: categoryId, + label: `Category ${categoryIndex + 1}`, + icon: 'layout-template' as const, + children: Array.from({ length: VIRTUAL_TREE_ITEMS }, (_, itemIndex) => { + nodeCount++; + + return { + id: `${categoryId}-item-${itemIndex}`, + label: `Item ${itemIndex + 1}`, + icon: 'file-text' as const, + }; + }), + }; + }), + }; + }); + + return { items, nodeCount, defaultExpanded }; +} + +const { + items: nestedVirtualTreeItems, + nodeCount: nestedVirtualTreeNodeCount, + defaultExpanded: nestedVirtualTreeExpanded, +} = createNestedVirtualTree(); + +export const Virtualized: Story = { + render: (args) => ({ + components: { Tree }, + setup() { + const selected = ref([]); + const expanded = ref([...nestedVirtualTreeExpanded]); + + return { + args, + selected, + expanded, + nestedVirtualTreeItems, + nestedVirtualTreeNodeCount, + VIRTUAL_TREE_BRANCHES, + VIRTUAL_TREE_CATEGORIES, + VIRTUAL_TREE_ITEMS, + }; + }, + template: ` +
+

+ {{ nestedVirtualTreeNodeCount }} nodes across + {{ VIRTUAL_TREE_BRANCHES }} departments, + {{ VIRTUAL_TREE_CATEGORIES }} categories each, + {{ VIRTUAL_TREE_ITEMS }} items per category +

+ +
+ `, + }), + args: { + items: nestedVirtualTreeItems, + virtualized: true, + estimateSize: 32, + disabled: false, + multiple: false, + showExpandArrow: true, + }, +}; + +export const Controlled: Story = { + render: () => ({ + components: { Tree }, + setup() { + const controlledSelected = ref(['all-workflows']); + const controlledExpanded = ref(['workflows']); + + function selectCredentials() { + controlledSelected.value = ['credentials']; + } + + function clearSelection() { + controlledSelected.value = []; + } + + function expandAll() { + controlledExpanded.value = ['workflows', 'credentials']; + } + + function collapseAll() { + controlledExpanded.value = []; + } + + return { + controlledDemoItems, + controlledSelected, + controlledExpanded, + selectCredentials, + clearSelection, + expandAll, + collapseAll, + }; + }, + template: ` +
+
+

+ Controlled +

+

+ Selection and expansion are owned by the parent via + v-model and v-model:expanded. +

+ +
+
+
Selected
+
{{ controlledSelected.join(', ') || 'none' }}
+
+
+
Expanded
+
{{ controlledExpanded.join(', ') || 'none' }}
+
+
+ +
+ + + + +
+ +
+ +
+
+ +
+

+ Uncontrolled +

+

+ Initial state is set with default-value and + default-expanded. The tree manages its own state after mount. +

+ +
+ +
+
+
+ `, + }), + args: { + items: controlledDemoItems, + disabled: false, + multiple: false, + showExpandArrow: true, + }, +}; + +type EmojiTreeBranch = Omit & { + emoji?: string; + children?: EmojiTreeBranch[]; +}; + +const customNodeItems: EmojiTreeBranch[] = [ + { + id: 'planets', + label: 'Planets', + emoji: 'πŸͺ', + children: [ + { + id: 'inner', + label: 'Inner solar system', + emoji: 'β˜€οΈ', + children: [ + { id: 'mercury', label: 'Mercury', emoji: '☿️' }, + { id: 'venus', label: 'Venus', emoji: '♀️' }, + { + id: 'earth', + label: 'Earth', + emoji: '🌍', + children: [ + { + id: 'continents', + label: 'Continents', + emoji: 'πŸ—ΊοΈ', + children: [ + { + id: 'europe', + label: 'Europe', + emoji: 'πŸ‡ͺπŸ‡Ί', + children: [ + { + id: 'france', + label: 'France', + emoji: 'πŸ‡«πŸ‡·', + children: [ + { id: 'paris', label: 'Paris', emoji: 'πŸ—Ό' }, + { id: 'lyon', label: 'Lyon', emoji: 'πŸ™οΈ' }, + ], + }, + { + id: 'germany', + label: 'Germany', + emoji: 'πŸ‡©πŸ‡ͺ', + children: [{ id: 'berlin', label: 'Berlin', emoji: '🐻' }], + }, + ], + }, + { + id: 'asia', + label: 'Asia', + emoji: '🌏', + children: [{ id: 'japan', label: 'Japan', emoji: 'πŸ‡―πŸ‡΅' }], + }, + ], + }, + ], + }, + { id: 'mars', label: 'Mars', emoji: 'πŸ”΄' }, + ], + }, + { + id: 'outer', + label: 'Outer solar system', + emoji: '🌌', + children: [ + { id: 'jupiter', label: 'Jupiter', emoji: '🟠' }, + { id: 'saturn', label: 'Saturn', emoji: 'πŸͺ' }, + { id: 'uranus', label: 'Uranus', emoji: 'πŸ”΅' }, + { id: 'neptune', label: 'Neptune', emoji: 'πŸŒ€' }, + ], + }, + ], + }, + { + id: 'animals', + label: 'Animals', + emoji: '🦁', + children: [ + { + id: 'mammals', + label: 'Mammals', + emoji: '🐻', + children: [ + { + id: 'bears', + label: 'Bears', + emoji: 'πŸ»β€β„οΈ', + children: [ + { id: 'grizzly', label: 'Grizzly bear', emoji: '🐻' }, + { id: 'polar', label: 'Polar bear', emoji: 'πŸ»β€β„οΈ' }, + ], + }, + { id: 'cats', label: 'Cats', emoji: '🐱' }, + ], + }, + { id: 'birds', label: 'Birds', emoji: 'πŸ¦…' }, + { id: 'fish', label: 'Fish', emoji: '🐠' }, + ], + }, + { + id: 'weather', + label: 'Weather', + emoji: '🌀️', + children: [ + { id: 'sunny', label: 'Sunny', emoji: 'β˜€οΈ' }, + { id: 'rainy', label: 'Rainy', emoji: '🌧️' }, + { id: 'snowy', label: 'Snowy', emoji: '❄️' }, + ], + }, +]; + +const customNodeDefaultExpanded = [ + 'planets', + 'inner', + 'earth', + 'continents', + 'europe', + 'france', + 'outer', + 'animals', + 'mammals', + 'bears', +]; + +function getCustomNodeProps({ item }: TreeNodeContext): { + label: string; + emoji?: string; +} { + const treeItem = item.value; + + return { label: treeItem.label, emoji: treeItem.emoji }; +} + +const customNodeClass = { + treeItem: 'sb-tree-custom-item', + treeItemSelected: 'sb-tree-custom-item--selected', + treeItemDisabled: 'sb-tree-custom-item--disabled', + treeItemTrackline: 'sb-tree-custom-item__trackline', + treeItemEmoji: 'sb-tree-custom-item__emoji', + treeItemLabel: 'sb-tree-custom-item__label', + treeItemToggle: 'sb-tree-custom-item__toggle', + treeItemToggleIcon: 'sb-tree-custom-item__toggle-icon', + treeItemToggleIconExpanded: 'sb-tree-custom-item__toggle-icon--expanded', +} as const; + +const customNodeStyles = ` +.${customNodeClass.treeItem} { + display: flex; + align-items: center; + gap: var(--spacing--2xs); + box-sizing: border-box; + width: 100%; + height: var(--tree-item-height); + position: relative; + padding-block: var(--tree-item-padding-block); + padding-inline: var(--tree-item-padding-inline); + padding-left: calc( + var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--tree-indent, 0) + ); + border-radius: var(--radius--lg); + cursor: pointer; + user-select: none; + color: var(--color--text--shade-1); + background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800)); + transition: + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.${customNodeClass.treeItem}[data-has-toggle] { + padding-right: calc(var(--tree-item-padding-inline) * 2); +} + +.${customNodeClass.treeItem}:hover:not([data-disabled]) { + background-color: light-dark(var(--color--neutral-200), var(--color--neutral-700)); +} + +.${customNodeClass.treeItem}:active:not([data-disabled]) { + box-shadow: inset 0 0 0 1px var(--border-color--subtle); +} + +[role='treeitem']:focus-visible .${customNodeClass.treeItem} { + outline: var(--focus--border-width) solid var(--focus--outline-color); + outline-offset: 0; +} + +.${customNodeClass.treeItemSelected} { + color: var(--color--primary); + background-color: light-dark(var(--color--primary--tint-3), var(--color--primary--shade-3)); + box-shadow: inset 3px 0 0 var(--color--primary); +} + +.${customNodeClass.treeItemSelected}:hover:not([data-disabled]), +.${customNodeClass.treeItemSelected}:active:not([data-disabled]), +[role='treeitem']:focus-visible .${customNodeClass.treeItemSelected} { + background-color: light-dark(var(--color--primary--tint-3), var(--color--primary--shade-3)); +} + +.${customNodeClass.treeItemSelected} .${customNodeClass.treeItemEmoji} { + background-color: light-dark(var(--color--background--light-1), var(--color--background--dark-1)); +} + +.${customNodeClass.treeItemDisabled} { + cursor: not-allowed; + opacity: 0.5; +} + +.${customNodeClass.treeItemTrackline} { + position: absolute; + top: 0; + bottom: 0; + left: calc( + var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--indent) + + var(--tree-icon-size) / 2 + ); + z-index: 1; + width: 1px; + background-color: var(--border-color--subtle); + pointer-events: none; +} + +.${customNodeClass.treeItemEmoji} { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--tree-icon-size); + height: var(--tree-icon-size); + border-radius: var(--radius--full); + font-size: var(--font-size--sm); + line-height: 1; + background-color: light-dark(var(--color--background--light-1), var(--color--background--dark-1)); +} + +.${customNodeClass.treeItemToggle} { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + margin-left: auto; + border: none; + background: transparent; + color: var(--color--text--tint-1); + cursor: pointer; +} + +.${customNodeClass.treeItemToggle}:disabled { + cursor: not-allowed; +} + +.${customNodeClass.treeItemToggleIcon} { + font-size: var(--font-size--2xs); + line-height: 1; + transition: transform var(--duration--snappy) var(--easing--ease-out); +} + +@media (prefers-reduced-motion: reduce) { + .${customNodeClass.treeItemToggleIcon} { + transition: none; + } +} + +.${customNodeClass.treeItemToggleIconExpanded} { + transform: rotate(90deg); +} + +.${customNodeClass.treeItemLabel} { + flex: 1; + min-width: 0; + font-size: var(--font-size--2xs); + font-weight: var(--font-weight--medium); + line-height: var(--line-height--md); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +`; + +let customNodeStylesInjected = false; + +function ensureCustomNodeStyles() { + if (customNodeStylesInjected || typeof document === 'undefined') { + return; + } + + const style = document.createElement('style'); + style.textContent = customNodeStyles; + document.head.appendChild(style); + customNodeStylesInjected = true; +} + +const TreeNodeCustom = defineComponent({ + name: 'TreeNodeCustom', + props: { + label: { type: String, required: true }, + emoji: { type: String, required: false }, + disabled: { type: Boolean, default: false }, + showExpandArrow: { type: Boolean, default: true }, + isExpanded: { type: Boolean, required: true }, + isSelected: { type: Boolean, required: true }, + hasChildren: { type: Boolean, required: true }, + indentLevel: { type: Number, default: 0 }, + handleToggle: { type: Function as PropType<() => void>, required: true }, + handleSelect: { type: Function as PropType<() => void>, required: true }, + }, + setup() { + ensureCustomNodeStyles(); + + return { customNodeClass }; + }, + template: ` +
+ + + + + + {{ label }} + + + +
+ `, +}); + +export const CustomNode: Story = { + render: (args) => ({ + components: { Tree, TreeNodeCustom }, + setup() { + const selected = ref(['paris']); + const expanded = ref([...customNodeDefaultExpanded]); + + return { + args, + selected, + expanded, + customNodeItems, + getCustomNodeProps, + TreeNodeCustom, + }; + }, + template: ` +
+ +
+ `, + }), + args: { + items: customNodeItems, + disabled: false, + multiple: false, + showExpandArrow: true, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.test.ts b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.test.ts new file mode 100644 index 00000000000..5dfbd6247b9 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.test.ts @@ -0,0 +1,265 @@ +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/vue'; +import { defineComponent } from 'vue'; + +import type { TreeBranch, TreeProps } from './Tree.types'; +import Tree from './Tree.vue'; + +const treeItems: TreeBranch[] = [ + { + id: 'parent', + label: 'Parent', + icon: 'folder', + children: [ + { id: 'child', label: 'Child', icon: 'file-text' }, + { id: 'sibling', label: 'Sibling', icon: 'file-text' }, + ], + }, + { + id: 'leaf', + label: 'Leaf', + icon: 'variable', + }, +]; + +const defaultExpanded = ['parent']; + +type TreeRenderOptions = { + props?: Partial; + slots?: Record; + attrs?: Record; +}; + +function renderTree(options: TreeRenderOptions = {}) { + const { props: overrideProps, ...rest } = options; + + return render(Tree, { + ...rest, + props: { + items: treeItems, + defaultExpanded, + ...overrideProps, + }, + }); +} + +function getRowByLabel(wrapper: ReturnType, label: string) { + return wrapper.getByText(label).closest('[data-test-id="tree-node"]'); +} + +interface CustomItem extends TreeBranch { + uuid: string; + nodes?: CustomItem[]; +} + +const customItems: CustomItem[] = [ + { + id: 'parent', + uuid: 'parent-uuid', + label: 'Custom parent', + nodes: [{ id: 'child', uuid: 'child-uuid', label: 'Custom child' }], + }, +]; + +function renderCustomTree(props?: Partial) { + return render(Tree, { + props: { + items: customItems, + defaultExpanded: ['parent-uuid'], + getKey: (item) => (item as CustomItem).uuid, + getChildren: (item) => (item as CustomItem).nodes, + ...props, + }, + }); +} + +const CustomNode = defineComponent({ + props: { + label: { type: String, required: true }, + }, + template: '{{ label }}', +}); + +describe('v2/components/Tree', () => { + describe('rendering', () => { + it('should render with default data-test-id on root', () => { + const wrapper = renderTree(); + + expect(wrapper.getByTestId('tree')).toBeInTheDocument(); + }); + + it('should allow overriding root data-test-id', () => { + const wrapper = renderTree({ + attrs: { 'data-test-id': 'sidebar-tree' }, + }); + + expect(wrapper.getByTestId('sidebar-tree')).toBeInTheDocument(); + }); + + it('should forward non-class attributes to root', () => { + const wrapper = renderTree({ + attrs: { 'aria-label': 'Navigation tree' }, + }); + + expect(wrapper.getByTestId('tree')).toHaveAttribute('aria-label', 'Navigation tree'); + }); + + it('should propagate disabled state to rows', () => { + const wrapper = renderTree({ + props: { disabled: true }, + }); + + for (const node of wrapper.getAllByTestId('tree-node')) { + expect(node).toHaveAttribute('data-disabled'); + } + }); + }); + + describe('v-model', () => { + it('should emit selected item keys on row click', async () => { + const wrapper = renderTree(); + + await userEvent.click(wrapper.getByText('Child')); + + await waitFor(() => { + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child']]); + }); + }); + + it('should reflect controlled modelValue keys as selected rows', () => { + const wrapper = renderTree({ + props: { modelValue: ['child'] }, + }); + + expect(getRowByLabel(wrapper, 'Child')).toHaveAttribute('data-selected'); + }); + + it('should support multiple selection with key arrays', async () => { + const wrapper = renderTree({ + props: { + multiple: true, + modelValue: ['child'], + }, + }); + + await userEvent.click(wrapper.getByText('Sibling')); + + await waitFor(() => { + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child', 'sibling']]); + }); + }); + }); + + describe('defaultValue', () => { + it('should use defaultValue keys for initial selection', () => { + const wrapper = renderTree({ + props: { defaultValue: ['child'] }, + }); + + expect(getRowByLabel(wrapper, 'Child')).toHaveAttribute('data-selected'); + }); + + it('should emit keys when toggling from defaultValue', async () => { + const wrapper = renderTree({ + props: { defaultValue: ['child'] }, + }); + + await userEvent.click(wrapper.getByText('Sibling')); + + await waitFor(() => { + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['sibling']]); + }); + }); + }); + + describe('custom data accessors', () => { + it('should resolve keys and children from custom accessors', async () => { + const wrapper = renderCustomTree(); + + expect(wrapper.getByText('Custom child')).toBeInTheDocument(); + + await userEvent.click(wrapper.getByText('Custom child')); + + await waitFor(() => { + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child-uuid']]); + }); + }); + }); + + describe('slots', () => { + test.each([ + ['icon', 'icon', 'custom-icon'], + ['label', 'label', 'custom-label'], + [ + 'toggle', + '', + 'custom-toggle', + ], + ] as const)('should render %s slot content', (slotName, slotContent, testId) => { + const wrapper = renderTree({ + slots: { [slotName]: slotContent }, + }); + + expect(wrapper.getAllByTestId(testId).length).toBeGreaterThan(0); + }); + + it('should render default slot content instead of default row', () => { + const wrapper = renderTree({ + slots: { + default: '
custom row
', + }, + }); + + expect(wrapper.getAllByTestId('custom-row').length).toBeGreaterThan(0); + expect(wrapper.queryByTestId('tree-node-label')).not.toBeInTheDocument(); + }); + + it('should pass label to label slot scope', () => { + const wrapper = renderTree({ + slots: { + label: + '', + }, + }); + + const childLabel = wrapper + .getAllByTestId('scoped-label') + .find((element) => element.textContent === 'Child'); + + expect(childLabel).toBeInTheDocument(); + }); + }); + + describe('custom node', () => { + it('should render custom node component with item props', () => { + const wrapper = renderTree({ + props: { node: CustomNode }, + }); + + expect( + wrapper.getAllByTestId('custom-node').find((node) => node.textContent === 'Child'), + ).toHaveTextContent('Child'); + }); + + it('should map getNodeProps to custom node props', () => { + const wrapper = renderTree({ + props: { + node: CustomNode, + getNodeProps: () => ({ label: 'Mapped label' }), + }, + }); + + expect(wrapper.getAllByText('Mapped label').length).toBeGreaterThan(0); + }); + }); + + describe('virtualized', () => { + it('should mount TreeVirtualizer when virtualized', () => { + const wrapper = renderTree({ + props: { virtualized: true }, + }); + + expect(wrapper.container.querySelector('[data-reka-virtualizer]')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.types.ts b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.types.ts new file mode 100644 index 00000000000..53b64e6953e --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.types.ts @@ -0,0 +1,94 @@ +import type { FlattenedItem, TreeRootEmits, TreeRootProps } from 'reka-ui'; +import type { Component } from 'vue'; + +import type { IconName } from '@n8n/design-system/components/N8nIcon/icons'; + +export type TreeItem = { + id: string; + label: string; + icon?: IconName; +}; + +export interface TreeBranch extends TreeItem { + children?: TreeBranch[]; +} + +export type TreeNodeContext = { + item: FlattenedItem; + handleToggle: () => void; + handleSelect: () => void; + isExpanded: boolean; + hasChildren: boolean; +}; + +export type TreeDefaultNodeProps = { + label: string; +}; + +export type TreeGetNodeProps< + T extends TreeItem = TreeBranch, + P extends Record = TreeDefaultNodeProps, +> = (context: TreeNodeContext) => P; + +export type TreeProps = Omit< + TreeRootProps, + 'asChild' | 'as' | 'getKey' | 'getChildren' | 'modelValue' | 'defaultValue' +> & { + items: TreeBranch[]; + getKey?: (item: TreeBranch) => string; + getChildren?: (item: TreeBranch) => TreeBranch[] | undefined; + modelValue?: string[]; + defaultValue?: string[]; + showExpandArrow?: boolean; + virtualized?: boolean; + estimateSize?: number; + overscan?: number; + textContent?: (item: TreeBranch) => string; + node?: Component; + getNodeProps?: TreeGetNodeProps; +}; + +export type TreeEmits = Omit, 'update:modelValue'> & { + 'update:modelValue': [value: string[]]; +}; + +export type TreeDefaultSlotProps = TreeNodeContext; + +type TreeNodeDefaultIconSlotProps = { + icon?: IconName; + disabled?: boolean; + isSelected: boolean; + ui: { class: string }; +}; + +type TreeNodeDefaultLabelSlotProps = { + label: string; + disabled?: boolean; + handleSelect: () => void; + ui: { class: string }; +}; + +type TreeNodeDefaultToggleSlotProps = { + label: string; + disabled?: boolean; + isExpanded: boolean; + handleToggle: () => void; + ui: { + class: string; + iconClass: string; + iconExpandedClass: string; + }; +}; + +export type TreeNodeDefaultSlots = { + icon?: (props: TreeNodeDefaultIconSlotProps) => unknown; + label?: (props: TreeNodeDefaultLabelSlotProps) => unknown; + toggle?: (props: TreeNodeDefaultToggleSlotProps) => unknown; +}; + +export type TreeSlots = { + default?: (props: TreeDefaultSlotProps) => unknown; + icon?: (props: TreeNodeDefaultIconSlotProps) => unknown; + label?: (props: TreeNodeDefaultLabelSlotProps) => unknown; + toggle?: (props: TreeNodeDefaultToggleSlotProps) => unknown; +}; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.variables.module.css b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.variables.module.css new file mode 100644 index 00000000000..5d1a4d032bf --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.variables.module.css @@ -0,0 +1,7 @@ +.root { + --tree-item-height: var(--spacing--xl); + --tree-icon-size: var(--spacing--lg); + --tree-item-padding-block: var(--spacing--4xs); + --tree-item-padding-inline: var(--tree-item-padding-block); + --tree-indent-unit: calc(var(--tree-item-padding-inline) + var(--tree-icon-size)); +} diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.vue b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.vue new file mode 100644 index 00000000000..fdcd63a09e8 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/Tree.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNode.vue b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNode.vue new file mode 100644 index 00000000000..a02cb312682 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNode.vue @@ -0,0 +1,122 @@ + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.stories.ts b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.stories.ts new file mode 100644 index 00000000000..fd01290d359 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.stories.ts @@ -0,0 +1,270 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { ref } from 'vue'; + +import type { IconName } from '@n8n/design-system/components/N8nIcon/icons'; + +import treeVariables from './Tree.variables.module.css'; +import TreeNodeDefault from './TreeNodeDefault.vue'; +import Checkbox from '../Checkbox/Checkbox.vue'; + +const noop = () => undefined; + +const longLabel = 'Quarterly automation rollout for customer onboarding and billing reconciliation'; + +type VariantProps = { + label: string; + icon?: IconName; + disabled?: boolean; + showExpandArrow?: boolean; + isExpanded: boolean; + isSelected: boolean; + hasChildren: boolean; +}; + +const variants: Array<{ name: string; props: VariantProps }> = [ + { + name: 'Default', + props: { + label: 'Workflows', + icon: 'bolt-filled', + isExpanded: false, + isSelected: false, + hasChildren: false, + }, + }, + { + name: 'Selected', + props: { + label: 'All workflows', + icon: 'list-tree', + isExpanded: false, + isSelected: true, + hasChildren: false, + }, + }, + { + name: 'Disabled', + props: { + label: 'Credentials', + icon: 'lock', + isExpanded: false, + isSelected: true, + hasChildren: false, + disabled: true, + }, + }, + { + name: 'Branch (collapsed)', + props: { + label: 'Executions', + icon: 'list', + isExpanded: false, + isSelected: false, + hasChildren: true, + }, + }, + { + name: 'Branch (expanded)', + props: { + label: 'Executions', + icon: 'list', + isExpanded: true, + isSelected: false, + hasChildren: true, + }, + }, + { + name: 'Without expand arrow', + props: { + label: 'Settings', + icon: 'settings', + isExpanded: false, + isSelected: false, + hasChildren: true, + showExpandArrow: false, + }, + }, + { + name: 'Long label', + props: { + label: longLabel, + icon: 'list-tree', + isExpanded: false, + isSelected: false, + hasChildren: true, + }, + }, + { + name: 'Without icon', + props: { + label: 'Variables', + isExpanded: false, + isSelected: false, + hasChildren: false, + }, + }, +]; + +const meta = { + title: 'Experimental/Tree/TreeNodeDefault', + component: TreeNodeDefault, + tags: ['autodocs'], + parameters: { + docs: { + source: { type: 'dynamic' }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Workflows', + icon: 'bolt-filled', + isExpanded: false, + isSelected: false, + hasChildren: false, + handleToggle: noop, + handleSelect: noop, + }, + render: (args) => ({ + components: { TreeNodeDefault }, + setup() { + return { args, treeVariables, noop }; + }, + template: ` +
    + +
+ `, + }), +}; + +export const Variants: Story = { + render: () => ({ + components: { TreeNodeDefault, Checkbox }, + setup() { + const checkboxSelected = ref(false); + const checkboxDisabledSelected = ref(true); + + function toggleCheckboxSelected() { + checkboxSelected.value = !checkboxSelected.value; + } + + return { + variants, + treeVariables, + noop, + checkboxSelected, + checkboxDisabledSelected, + toggleCheckboxSelected, + }; + }, + template: ` +
+
+ + {{ variant.name }} + +
    + +
+
+ +
+ + Checkbox icon (interactive) + +
    + + + +
+
+ +
+ + Checkbox icon (disabled) + +
    + + + +
+
+
+ `, + }), + args: { + label: 'Workflows', + icon: 'bolt-filled', + isExpanded: false, + isSelected: false, + hasChildren: false, + handleToggle: noop, + handleSelect: noop, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.vue b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.vue new file mode 100644 index 00000000000..fe2626dbff0 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/TreeNodeDefault.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/component-tree.md b/packages/frontend/@n8n/design-system/src/v2/components/Tree/component-tree.md index cb633502a14..adb581b687e 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/Tree/component-tree.md +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/component-tree.md @@ -1,107 +1,260 @@ # Component specification -A generic tree component that displays hierarchical data in a collapsible structure. It supports virtualization for performance with large datasets, provides flexible rendering through slots, and can work with any data type that implements the `TreeItem` interface. +A tree component that displays hierarchical data in a collapsible structure. It wraps [Reka UI Tree](https://reka-ui.com/docs/components/tree) with n8n styling, a default row layout (`TreeNodeDefault`), and slots for customizing icons, labels, and expand toggles. Custom row components can be supplied via the `node` prop. -- **Component Name:** N8nTree +- **Component Name:** N8nTree2 - **Figma Component:** [Tree](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?m=auto&node-id=2536-2108) - **Reka UI Component:** [Tree](https://reka-ui.com/docs/components/tree) -## Generic Type Support +## Component structure -The Tree component is generic and can work with any data type that extends the base `TreeItem` interface: +``` +Tree (N8nTree2) +└── TreeNode (per flattened item) + β”œβ”€β”€ #default slot β†’ fully custom row + β”œβ”€β”€ custom `node` component (via `node` + `getNodeProps`) + └── TreeNodeDefault (default row: icon, label, expand toggle) +``` + +## Data model + +Items use the `TreeBranch` shape by default. Each node requires `id` and `label`; `icon` and `children` are optional. Individual items may also define `disabled?: boolean`. ```typescript -interface TreeItem { +type TreeItem = { id: string; - children?: TreeItem[]; + label: string; + icon?: IconName; +}; + +interface TreeBranch extends TreeItem { + children?: TreeBranch[]; } ``` +Use `getKey` and `getChildren` when item keys or nesting are not stored on `id` / `children`. ## Public API Definition **Props** -- `items: T[]` - Tree data structure using generic type T (defaults to IMenuItem) -- `estimateSize?: number` - Estimated height of each tree item (in px) - used for virtualization | `default: 32` -- `getKey?: (item: T) => string` - Optional function to get unique key from each item | `default: item.id` -- `modelValue?: string[]` - The controlled selected values of the Tree. Can be bind as `v-model` -- `expanded?: string[]` - The controlled expanded state of tree items. Can be bind as `v-model:expanded` -- `defaultExpanded?: string[]` - The expanded state when initially rendered -- `multiple?: boolean` - Whether multiple items can be selected -- `disabled?: boolean` - When `true`, prevents user interaction with the Tree +- `items: TreeBranch[]` β€” Tree data +- `getKey?: (item: TreeBranch) => string` β€” Unique key per item | `default: item.id` +- `getChildren?: (item: TreeBranch) => TreeBranch[] | undefined` β€” Child nodes | `default: item.children` +- `modelValue?: string[]` β€” Controlled selected item keys. Bind with `v-model` +- `defaultValue?: string[]` β€” Initial selected keys when uncontrolled +- `expanded?: string[]` β€” Controlled expanded item keys. Bind with `v-model:expanded` +- `defaultExpanded?: string[]` β€” Initial expanded keys when uncontrolled +- `multiple?: boolean` β€” Allow selecting multiple items | `default: false` +- `disabled?: boolean` β€” Disable the entire tree | `default: false` +- `showExpandArrow?: boolean` β€” Show chevron toggle on expandable rows | `default: true` +- `virtualized?: boolean` β€” Virtualize the flattened list for large trees | `default: false` +- `estimateSize?: number` β€” Estimated row height in px when virtualized | `default: 32` +- `overscan?: number` β€” Number of rows rendered outside the visible area when virtualized +- `textContent?: (item: TreeBranch) => string` β€” Label text for type-ahead when virtualized | `default: item.label` +- `node?: Component` β€” Custom Vue component for each row (replaces `TreeNodeDefault`) +- `getNodeProps?: (context: TreeNodeContext) => Record` β€” Maps tree context to props for `node`. Defaults to `{ label: item.label }` **Events** -- `update:modelValue(value: string[])` - Emitted when selection changes -- `update:expanded([val: Record | Record[]])` - Emitted when expanded items change +- `update:modelValue(value: string[])` β€” Emitted when selection changes +- `update:expanded(value: string[])` β€” Emitted when expanded items change **Slots** -- `default`: `{ item: FlattenedItem; handleToggle: () => void; handleSelect: () => void; isExpanded: boolean, hasChildren: boolean }` - Main content for each tree item. -- `empty`: - Slot for empty state. +All slots are forwarded from `Tree` β†’ `TreeNode` β†’ `TreeNodeDefault` unless `#default` replaces the entire row. -### Template usage example +- `default`: `TreeNodeContext` β€” Fully custom row content. Replaces the default node renderer. +- `icon`: `{ icon?: IconName; disabled?: boolean; isSelected: boolean; ui: { class: string } }` β€” Leading icon area (default: item icon) +- `label`: `{ label: string; disabled?: boolean; handleSelect: () => void; ui: { class: string } }` β€” Label area +- `toggle`: `{ label: string; disabled?: boolean; isExpanded: boolean; handleToggle: () => void; ui: { class: string; iconClass: string; iconExpandedClass: string } }` β€” Expand/collapse control (shown when `showExpandArrow` and item has children) -#### Basic Usage +**TreeNodeContext** + +```typescript +type TreeNodeContext = { + item: FlattenedItem; + handleToggle: () => void; + handleSelect: () => void; + isExpanded: boolean; + hasChildren: boolean; +}; +``` + +### Template usage examples + +#### Basic usage ```vue ``` -#### Custom slot and key +#### Uncontrolled + +```vue + +``` + +#### Custom key and children ```vue ``` + +#### Custom node component + +Pass a Vue component to `:node` and map item data with `:get-node-props`. See the `CustomNode` story in Storybook for a full inline example. + +```vue + + + +``` + +#### Slot customization (checkbox icon) + +Use `#icon` to replace the leading icon. Row selection is handled by the default row click target (`#label` area); keep the checkbox non-interactive or use `#default` for full control. + +```vue + + + +``` + +#### Fully custom row (`#default`) + +```vue + + + +``` + +## Related components + +- **TreeNodeDefault** β€” Default row renderer used inside `Tree`. Documented separately under `Experimental/Tree/TreeNodeDefault` in Storybook. +- **N8nTree** β€” Legacy tree component in `@n8n/design-system/components`. Use `N8nTree2` for new work. diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tree/index.ts b/packages/frontend/@n8n/design-system/src/v2/components/Tree/index.ts new file mode 100644 index 00000000000..4e83c83ef2b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tree/index.ts @@ -0,0 +1,2 @@ +export { default as N8nTree2 } from './Tree.vue'; +export type * from './Tree.types';