sidebar component structure

This commit is contained in:
Rob Squires 2025-08-08 10:23:33 +01:00
parent 85576f5d93
commit 6a5b523d64
5 changed files with 912 additions and 130 deletions

View File

@ -0,0 +1,662 @@
import type { StoryFn } from '@storybook/vue3';
import Sidebar from './Sidebar.vue';
import { type TreeItemType } from '.';
export default {
title: 'Sidebar/Sidebar',
component: Sidebar,
};
const mockPersonalItems: TreeItemType[] = [
{
id: 'personal-folder-1',
label: 'Customer Management',
type: 'folder',
children: [
{
id: 'personal-subfolder-1',
label: 'Onboarding Processes',
type: 'folder',
children: [
{
id: 'personal-subfolder-1-1',
label: 'New Customer Flows',
type: 'folder',
children: [
{
id: 'personal-subfolder-1-1-1',
label: 'Enterprise Customers',
type: 'folder',
children: [
{ id: 'workflow-1', label: 'Enterprise Customer Onboarding', type: 'workflow' },
{ id: 'workflow-2', label: 'Enterprise Account Setup', type: 'workflow' },
{ id: 'workflow-3', label: 'Enterprise Training Schedule', type: 'workflow' },
],
},
{
id: 'personal-subfolder-1-1-2',
label: 'Small Business Customers',
type: 'folder',
children: [
{ id: 'workflow-4', label: 'SMB Quick Onboarding', type: 'workflow' },
{ id: 'workflow-5', label: 'SMB Account Verification', type: 'workflow' },
],
},
],
},
{
id: 'personal-subfolder-1-2',
label: 'Returning Customer Flows',
type: 'folder',
children: [
{ id: 'workflow-6', label: 'Account Reactivation', type: 'workflow' },
{ id: 'workflow-7', label: 'Update Customer Info', type: 'workflow' },
],
},
],
},
{
id: 'personal-subfolder-2',
label: 'Support Operations',
type: 'folder',
children: [
{
id: 'personal-subfolder-2-1',
label: 'Ticket Management',
type: 'folder',
children: [
{
id: 'personal-subfolder-2-1-1',
label: 'Priority Handling',
type: 'folder',
children: [
{ id: 'workflow-8', label: 'Critical Issue Handler', type: 'workflow' },
{ id: 'workflow-9', label: 'Urgent Escalation', type: 'workflow' },
],
},
{ id: 'workflow-10', label: 'General Support Tickets', type: 'workflow' },
],
},
{ id: 'workflow-11', label: 'Customer Satisfaction Survey', type: 'workflow' },
],
},
],
},
{
id: 'personal-folder-2',
label: 'Analytics & Reporting',
type: 'folder',
children: [
{
id: 'personal-analytics-1',
label: 'Sales Analytics',
type: 'folder',
children: [
{
id: 'personal-analytics-1-1',
label: 'Daily Reports',
type: 'folder',
children: [
{
id: 'personal-analytics-1-1-1',
label: 'Revenue Tracking',
type: 'folder',
children: [
{
id: 'personal-analytics-1-1-1-1',
label: 'Product Line Analysis',
type: 'folder',
children: [
{ id: 'workflow-12', label: 'Software Revenue', type: 'workflow' },
{ id: 'workflow-13', label: 'Service Revenue', type: 'workflow' },
{ id: 'workflow-14', label: 'Subscription Revenue', type: 'workflow' },
],
},
{ id: 'workflow-15', label: 'Daily Revenue Report', type: 'workflow' },
],
},
{ id: 'workflow-16', label: 'Sales Performance', type: 'workflow' },
],
},
{
id: 'personal-analytics-1-2',
label: 'Weekly Reports',
type: 'folder',
children: [
{ id: 'workflow-17', label: 'Weekly Sales Summary', type: 'workflow' },
{ id: 'workflow-18', label: 'Team Performance Review', type: 'workflow' },
],
},
],
},
{
id: 'personal-analytics-2',
label: 'User Analytics',
type: 'folder',
children: [
{
id: 'personal-analytics-2-1',
label: 'Behavior Analysis',
type: 'folder',
children: [
{ id: 'workflow-19', label: 'User Activity Analysis', type: 'workflow' },
{ id: 'workflow-20', label: 'Feature Usage Tracking', type: 'workflow' },
],
},
],
},
],
},
{
id: 'personal-folder-3',
label: 'Development & Testing',
type: 'folder',
children: [
{
id: 'personal-dev-1',
label: 'Automated Testing',
type: 'folder',
children: [
{
id: 'personal-dev-1-1',
label: 'Unit Tests',
type: 'folder',
children: [
{
id: 'personal-dev-1-1-1',
label: 'Frontend Tests',
type: 'folder',
children: [
{
id: 'personal-dev-1-1-1-1',
label: 'Component Tests',
type: 'folder',
children: [
{ id: 'workflow-21', label: 'Button Component Test', type: 'workflow' },
{ id: 'workflow-22', label: 'Form Component Test', type: 'workflow' },
{ id: 'workflow-23', label: 'Modal Component Test', type: 'workflow' },
],
},
{ id: 'workflow-24', label: 'Service Tests', type: 'workflow' },
],
},
{ id: 'workflow-25', label: 'Backend API Tests', type: 'workflow' },
],
},
{ id: 'workflow-26', label: 'Integration Tests', type: 'workflow' },
],
},
{
id: 'personal-dev-2',
label: 'Empty Folder Test',
type: 'folder',
children: [],
},
],
},
{ id: 'workflow-27', label: 'Quick Data Sync', type: 'workflow' },
];
const mockSharedItems: TreeItemType[] = [
{
id: 'shared-folder-1',
label: 'Team Workflows',
type: 'folder',
children: [
{
id: 'shared-subfolder-1',
label: 'Development Team',
type: 'folder',
children: [
{
id: 'shared-subfolder-1-1',
label: 'Sprint Management',
type: 'folder',
children: [
{
id: 'shared-subfolder-1-1-1',
label: 'Daily Operations',
type: 'folder',
children: [
{
id: 'shared-subfolder-1-1-1-1',
label: 'Standup Automation',
type: 'folder',
children: [
{ id: 'workflow-28', label: 'Daily Standup Prep', type: 'workflow' },
{ id: 'workflow-29', label: 'Standup Summary Generator', type: 'workflow' },
{ id: 'workflow-30', label: 'Standup Follow-up Tasks', type: 'workflow' },
],
},
{ id: 'workflow-31', label: 'Sprint Velocity Tracking', type: 'workflow' },
],
},
{ id: 'workflow-32', label: 'Sprint Planning Assistant', type: 'workflow' },
],
},
{
id: 'shared-subfolder-1-2',
label: 'Code Quality',
type: 'folder',
children: [
{
id: 'shared-subfolder-1-2-1',
label: 'Review Processes',
type: 'folder',
children: [
{ id: 'workflow-33', label: 'PR Review Assignment', type: 'workflow' },
{ id: 'workflow-34', label: 'Code Review Notifications', type: 'workflow' },
{ id: 'workflow-35', label: 'Review Completion Tracker', type: 'workflow' },
],
},
{ id: 'workflow-36', label: 'Code Quality Metrics', type: 'workflow' },
],
},
],
},
{
id: 'shared-subfolder-2',
label: 'DevOps Team',
type: 'folder',
children: [
{
id: 'shared-subfolder-2-1',
label: 'Infrastructure',
type: 'folder',
children: [
{
id: 'shared-subfolder-2-1-1',
label: 'Monitoring & Alerts',
type: 'folder',
children: [
{
id: 'shared-subfolder-2-1-1-1',
label: 'System Health',
type: 'folder',
children: [
{ id: 'workflow-37', label: 'Server Health Check', type: 'workflow' },
{
id: 'workflow-38',
label: 'Database Performance Monitor',
type: 'workflow',
},
{ id: 'workflow-39', label: 'API Response Time Tracker', type: 'workflow' },
],
},
{ id: 'workflow-40', label: 'Alert Management System', type: 'workflow' },
],
},
{ id: 'workflow-41', label: 'Deployment Pipeline Monitor', type: 'workflow' },
],
},
{
id: 'shared-empty-folder',
label: 'Archive (Empty for Testing)',
type: 'folder',
children: [],
},
],
},
],
},
{
id: 'shared-folder-2',
label: 'Cross-Team Initiatives',
type: 'folder',
children: [
{
id: 'shared-folder-2-1',
label: 'Company-wide Projects',
type: 'folder',
children: [
{
id: 'shared-folder-2-1-1',
label: 'Digital Transformation',
type: 'folder',
children: [
{
id: 'shared-folder-2-1-1-1',
label: 'Process Automation',
type: 'folder',
children: [
{
id: 'shared-folder-2-1-1-1-1',
label: 'HR Automation',
type: 'folder',
children: [
{
id: 'workflow-42',
label: 'Employee Onboarding Automation',
type: 'workflow',
},
{ id: 'workflow-43', label: 'Leave Request Processing', type: 'workflow' },
{
id: 'workflow-44',
label: 'Performance Review Scheduler',
type: 'workflow',
},
],
},
{ id: 'workflow-45', label: 'Finance Approval Workflows', type: 'workflow' },
],
},
{ id: 'workflow-46', label: 'Digital Asset Management', type: 'workflow' },
],
},
{ id: 'workflow-47', label: 'Quarterly Business Review Generator', type: 'workflow' },
],
},
{ id: 'workflow-48', label: 'All-Hands Meeting Prep', type: 'workflow' },
],
},
{ id: 'workflow-49', label: 'Shared Dashboard Update', type: 'workflow' },
];
const mockProjects = [
{
id: 'project-1',
title: 'E-commerce Platform',
items: [
{
id: 'ecommerce-folder-1',
label: 'Order Processing',
type: 'folder',
children: [
{ id: 'workflow-9', label: 'Order Confirmation', type: 'workflow' },
{ id: 'workflow-10', label: 'Inventory Update', type: 'workflow' },
{ id: 'workflow-11', label: 'Shipping Notification', type: 'workflow' },
],
},
{
id: 'ecommerce-folder-2',
label: 'Customer Communication',
type: 'folder',
children: [
{
id: 'workflow-12',
label: 'Welcome Email Sequence',
type: 'workflow',
icon: 'workflow',
},
{
id: 'workflow-13',
label: 'Abandoned Cart Recovery',
type: 'workflow',
icon: 'workflow',
},
],
},
] as TreeItemType[],
},
{
id: 'project-2',
title: 'Marketing Automation',
items: [
{ id: 'workflow-14', label: 'Lead Scoring', type: 'workflow' },
{
id: 'workflow-15',
label: 'Campaign Performance Tracker',
type: 'workflow',
icon: 'workflow',
},
{
id: 'marketing-folder-1',
label: 'Social Media',
type: 'folder',
children: [
{ id: 'workflow-16', label: 'Auto Post Scheduler', type: 'workflow' },
{ id: 'workflow-17', label: 'Engagement Tracker', type: 'workflow' },
],
},
] as TreeItemType[],
},
{
id: 'project-3',
title: 'Stress Test - Deep Nesting',
items: [
{
id: 'stress-test-folder',
label: 'Level 1 - Root Organization',
type: 'folder',
children: [
{
id: 'stress-level-2',
label: 'Level 2 - Department Structure',
type: 'folder',
children: [
{
id: 'stress-level-3',
label:
'Level 3 - Team Subdivisions with Very Long Descriptive Names That Test UI Wrapping',
type: 'folder',
children: [
{
id: 'stress-level-4',
label:
'Level 4 - Project Categories and Workflow Types for Complex Business Process Management',
type: 'folder',
children: [
{
id: 'stress-level-5',
label:
'Level 5 - Specific Implementation Details and Granular Task Definitions',
type: 'folder',
children: [
{
id: 'stress-level-6',
label:
'Level 6 - Final Implementation Layer with Atomic Workflow Components',
type: 'folder',
children: [
{
id: 'stress-workflow-1',
label: 'Ultra-Deep Nested Workflow #1 - Data Processing Pipeline',
type: 'workflow',
},
{
id: 'stress-workflow-2',
label: 'Ultra-Deep Nested Workflow #2 - Error Handling System',
type: 'workflow',
},
{
id: 'stress-workflow-3',
label:
'Ultra-Deep Nested Workflow #3 - Performance Monitoring Suite',
type: 'workflow',
},
],
},
{
id: 'stress-workflow-4',
label: 'Level 5 Direct Workflow - Integration Manager',
type: 'workflow',
},
],
},
{
id: 'stress-workflow-5',
label: 'Level 4 Direct Workflow - Configuration Handler',
type: 'workflow',
},
],
},
{
id: 'stress-level-4-alternate',
label: 'Level 4 Alternate - Another Deep Branch for Comprehensive Testing',
type: 'folder',
children: [
{
id: 'stress-empty-deep',
label: 'Empty Folder at Level 5 - Testing Edge Cases',
type: 'folder',
children: [],
},
{
id: 'stress-workflow-6',
label: 'Alternative Branch Workflow - Backup Systems',
type: 'workflow',
},
],
},
],
},
{
id: 'stress-parallel-branch',
label: 'Level 3 Parallel Branch - Additional Complexity',
type: 'folder',
children: [
{
id: 'stress-workflow-7',
label: 'Parallel Branch Workflow - Load Balancer',
type: 'workflow',
},
{
id: 'stress-workflow-8',
label: 'Parallel Branch Workflow - Cache Manager',
type: 'workflow',
},
],
},
],
},
{
id: 'stress-many-items',
label: 'Level 2 - Folder with Many Direct Children',
type: 'folder',
children: [
{
id: 'many-workflow-1',
label: 'Bulk Test Workflow #01 - First in Series',
type: 'workflow',
},
{
id: 'many-workflow-2',
label: 'Bulk Test Workflow #02 - Second in Series',
type: 'workflow',
},
{
id: 'many-workflow-3',
label: 'Bulk Test Workflow #03 - Third in Series',
type: 'workflow',
},
{
id: 'many-workflow-4',
label: 'Bulk Test Workflow #04 - Fourth in Series',
type: 'workflow',
},
{
id: 'many-workflow-5',
label: 'Bulk Test Workflow #05 - Fifth in Series',
type: 'workflow',
},
{
id: 'many-workflow-6',
label: 'Bulk Test Workflow #06 - Sixth in Series',
type: 'workflow',
},
{
id: 'many-workflow-7',
label: 'Bulk Test Workflow #07 - Seventh in Series',
type: 'workflow',
},
{
id: 'many-workflow-8',
label: 'Bulk Test Workflow #08 - Eighth in Series',
type: 'workflow',
},
{
id: 'many-workflow-9',
label: 'Bulk Test Workflow #09 - Ninth in Series',
type: 'workflow',
},
{
id: 'many-workflow-10',
label: 'Bulk Test Workflow #10 - Tenth in Series',
type: 'workflow',
},
{
id: 'many-subfolder-1',
label: 'Subfolder Within Many Items - Testing Mixed Content',
type: 'folder',
children: [
{
id: 'nested-many-1',
label: 'Nested Within Many - Workflow A',
type: 'workflow',
},
{
id: 'nested-many-2',
label: 'Nested Within Many - Workflow B',
type: 'workflow',
},
],
},
{
id: 'many-workflow-11',
label: 'Bulk Test Workflow #11 - Eleventh in Series',
type: 'workflow',
},
{
id: 'many-workflow-12',
label: 'Bulk Test Workflow #12 - Final in Series',
type: 'workflow',
},
],
},
],
},
{
id: 'edge-cases-folder',
label: 'Edge Cases Testing',
type: 'folder',
children: [
{
id: 'empty-folder-test-1',
label: 'Empty Folder Test #1',
type: 'folder',
children: [],
},
{
id: 'empty-folder-test-2',
label: 'Empty Folder Test #2 with Longer Name for UI Testing',
type: 'folder',
children: [],
},
{
id: 'single-item-folder',
label: 'Single Item Folder',
type: 'folder',
children: [
{
id: 'single-workflow',
label: 'Lonely Workflow in Single Item Folder',
type: 'workflow',
},
],
},
],
},
] as TreeItemType[],
},
];
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
Sidebar,
},
template: `
<div class="story">
<Sidebar v-bind="args" />
</div>
`,
});
export const primary = Template.bind({});
primary.args = {
personal: mockPersonalItems,
shared: mockSharedItems,
projects: mockProjects,
};

View File

@ -0,0 +1,71 @@
<script lang="ts" setup>
import { TreeItemType } from '.';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
import SidebarSection from './SidebarSection.vue';
const props = defineProps<{
personal: TreeItemType[];
shared: TreeItemType[];
projects: { title: string; id: string; icon: 'string'; items: TreeItemType[] }[];
}>();
</script>
<template>
<nav class="sidebar">
<header class="sidebarItem">
<div class="icon">
<N8nIcon icon="house" />
</div>
<N8nText>Overview</N8nText>
</header>
<SidebarSection
title="Personal"
id="personal"
icon="user"
:items="props.personal"
:selectable="false"
:collapsible="false"
/>
<SidebarSection
title="Shared with me"
id="shared"
icon="share"
:items="props.shared"
:selectable="false"
:collapsible="false"
/>
<N8nText size="small" bold color="text-light" class="sidebarSubheader">Projects</N8nText>
<SidebarSection
v-for="project in props.projects"
:title="project.title"
:id="project.id"
:key="project.id"
:items="project.items"
:selectable="false"
:collapsible="false"
/>
</nav>
</template>
<style scoped>
.sidebarItem {
display: flex;
align-items: center;
padding: 4px;
}
.sidebarItem .icon {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebarSubheader {
margin: 16px 0 8px;
display: block;
}
</style>

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import { TreeRoot, TreeItem, TreeItemToggleEvent } from 'reka-ui';
import type { TreeItemType } from '.';
import { ref } from 'vue';
import { N8nIcon, N8nIconButton, N8nText } from '..';
import { IconName } from '../N8nIcon/icons';
interface Props {
title: string;
id: string;
icon?: IconName;
items: TreeItemType[];
}
const props = defineProps<Props>();
const open = ref<string[]>([]);
function toggleSection(id: string) {
if (open.value.includes(id)) {
open.value.splice(open.value.indexOf(id), 1);
} else {
open.value.push(id);
}
}
function preventDefault<T>(event: TreeItemToggleEvent<T>) {
if (event.detail.originalEvent.type === 'click') {
event.detail.originalEvent.preventDefault();
}
}
</script>
<template>
<div>
<header class="itemHeader">
<div class="dropdownButton">
<div class="icon">
<N8nIcon :icon="icon ?? 'layers'" />
</div>
<N8nIconButton
class="button"
square
type="tertiary"
text
size="small"
:icon="open.includes(id) ? 'chevron-down' : 'chevron-right'"
@click="toggleSection(id)"
:aria-label="`Toggle ${title} section`"
/>
</div>
<N8nText>{{ title }}</N8nText>
</header>
<div class="items">
<TreeRoot
v-if="open.includes(id)"
:items="props.items"
:get-key="(item: TreeItemType) => item.id"
v-slot="{ flattenItems }"
>
<TreeItem
v-for="item in flattenItems"
:key="item._id"
v-bind="item.bind"
v-slot="{ isExpanded, handleToggle }"
@toggle="preventDefault"
@select="preventDefault"
class="item"
>
<span class="itemIdent" v-for="level in new Array(item.level - 1)" :key="level" />
<div class="itemHeader">
<div class="dropdownButton" v-if="item.value.type === 'folder'">
<div class="icon">
<N8nIcon :icon="isExpanded ? 'folder-open' : 'folder'" />
</div>
<N8nIconButton
class="button"
square
type="tertiary"
text
size="small"
:icon="isExpanded ? 'chevron-down' : 'chevron-right'"
@click.stop="handleToggle"
:aria-label="`Toggle ${item.value.label} item`"
/>
</div>
<N8nText>{{ item.value.label }}</N8nText>
</div>
</TreeItem>
</TreeRoot>
</div>
</div>
</template>
<style lang="scss" scoped>
.items {
display: flex;
padding-left: 8px;
margin-left: 16px;
border-left: 1px solid var(--color-foreground-light);
}
.item {
display: flex;
align-items: center;
cursor: pointer;
}
.itemIdent {
display: block;
position: relative;
width: 8px;
align-self: stretch;
margin-left: 16px;
border-left: 1px solid var(--color-foreground-light);
}
.itemIdent::before {
content: '';
position: absolute;
bottom: -1px;
left: -1px;
width: 1px;
height: 1px;
background-color: var(--color-foreground-light);
}
.itemHeader {
display: flex;
align-items: center;
padding: 4px;
cursor: pointer;
}
.dropdownButton {
position: relative;
border-radius: var(--border-radius-small);
.icon {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.button {
opacity: 0;
}
&:hover .button,
.button:focus {
opacity: 1;
}
&:hover .icon,
&:focus-within .icon {
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,17 @@
export type TreeItemType = FolderItem | WorkflowItem;
export interface BaseItem {
id: string;
label: string;
disabled?: boolean;
data?: any;
}
export interface FolderItem extends BaseItem {
type: 'folder';
children?: TreeItemType[];
}
export interface WorkflowItem extends BaseItem {
type: 'workflow';
}

View File

@ -525,133 +525,3 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</N8nMenu>
</div>
</template>
<style lang="scss" module>
.sideMenu {
display: grid;
position: relative;
height: 100%;
grid-template-rows: auto 1fr auto;
border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
transition: width 150ms ease-in-out;
width: $sidebar-expanded-width;
background-color: var(--menu-background, var(--color-background-xlight));
.logo {
display: flex;
align-items: center;
padding: var(--spacing-xs);
justify-content: space-between;
img {
position: relative;
left: 1px;
height: 20px;
}
}
&.sideMenuCollapsed {
width: $sidebar-width;
min-width: auto;
.logo {
flex-direction: column;
gap: 12px;
}
}
}
.sideMenuCollapseButton {
position: absolute;
right: -10px;
top: 50%;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
background-color: var(--color-foreground-xlight);
width: 20px;
height: 20px;
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
border-radius: 50%;
&:hover {
color: var(--color-primary-shade-1);
}
}
.updates {
display: flex;
align-items: center;
cursor: pointer;
padding: var(--spacing-2xs) var(--spacing-l);
margin: var(--spacing-2xs) 0 0;
svg {
color: var(--color-text-base) !important;
}
span {
display: none;
&.expanded {
display: initial;
}
}
&:hover {
&,
& svg {
color: var(--color-text-dark) !important;
}
}
}
.userArea {
display: flex;
padding: var(--spacing-xs);
align-items: center;
height: 60px;
border-top: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
.userName {
display: none;
overflow: hidden;
width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
&.expanded {
display: initial;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
.userActions {
display: none;
&.expanded {
display: initial;
}
}
}
@media screen and (max-height: 470px) {
:global(#help) {
display: none;
}
}
.readOnlyEnvironmentIcon {
display: inline-block;
color: white;
background-color: var(--color-warning);
align-self: center;
padding: 2px;
border-radius: var(--border-radius-small);
margin: 7px 12px 0 5px;
}
</style>