feat(editor): updated n8n-menu component (#4290)

* refactor(editor): N8N-4540 Main navigation layout rework (#4060)

*  Implemented new editor layout using css grid

*  Reworking main navigation layout, migrating some styling to css modules

*  Reworking main sidebar layout and responsiveness

* 💄 Minor type update

*  Updated editor grid layout so empty cells are collapsed (`fit-content`), fixed updates menu items styling

*  Implemented new user area look & feel in main sidebar

* 💄 Adjusting sidebar bottom padding when user area is not shown

* 💄 CSS cleanup/refactor + minor vue refactoring

*  Fixing overscoll issue in chrome and scrolling behaviour of the content view

* 👌 Addressing review feedback

*  Added collapsed and expanded versions of n8n logo

*  Updating infinite scrolling in templates view to work with the new layout

* 💄 Updating main sidebar expanded width and templates view left margin

* 💄 Updating main content height

* 💄 Adding global styles for scrollable views with centered content, minor updates to user area

*  Updating zoomToFit logic, lasso select box position and new nodes positioning

*  Fixing new node drop position now that mouse detection has been adjusted

* 👌 Updating templates view scroll to top logic and responsive padding, aligning menu items titles

* 💄 Moving template layout style from global css class to component level

*  Moved 'Workflows'  menu to node view header. Added new dropdown component for user area and the new WF menu

* 💄 Updating disabled states in new WF menu

* 💄 Initial stab at new sidebar styling

*  Finished main navigation restyling

*  Updating `zoomToFit` and centering logic

*  Adding updates menu item to settings sidebar

* 💄 Adding updates item to the settings sidebar and final touches on main sidebar style

* 💄 Removing old code & refactoring

* 💄 Minor CSS tweaks

* 💄 Opening credentials modal on sidebar menu item click. Minor CSS updates

* 💄 Updating sidebar expand/collapse animation

* 💄 Few more refinements of sidebar animation

* 👌 Addressing code review comments

*  Moved ActionDropdown component to design system

* 👌 Fixing bugs reported during code review and testing

* 👌 Addressing design review comments for the new sidebar

* ✔️ Updating `N8nActionDropdown` component tests

*  Remembering scroll position when going back to templates list

*  Updating zoomToFit logic to account for footer content

* 👌 Addressing latest sidebar review comments

*  New `n8n-menu-item` component

*  Implemented new `n8n-menu` design system component, updated menu items to support collapsed mode

* Minor update to n8n-menu storybook entry

* 💄 Updating collapsed sub-menu style. Fixing vue error on hover.

*  Changing IMenuItem from interface to type

*  Added new n8n-menu component to editor main sidebar

*  Finished main sidebar

*  Added new menus to setttings and credentials view

*  Implemented tab and router modes for n8n-menu

*  Implemented credentials menus using new n8n-menu component

* 💄 Finishing main and settings sidebar details, updating design system stories

* 💄 Adding injected items support to main sidebar, handling navigation errors, small tweaks

* ✔️ Fixing linting errors

* ✔️ Addressing typescript errors in design system components

*  Using design-system types in editor UI

* 💄 Add support for custom icon size in menu items

* 👌 Addressing code review and design review feedback

* 💄 Minor updates
This commit is contained in:
Milorad FIlipović 2022-10-10 18:17:39 +02:00 committed by GitHub
parent d47ff48fb6
commit 6af3ba75dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1107 additions and 778 deletions

View file

@ -1,46 +0,0 @@
import N8nMenu from './Menu.vue';
import N8nMenuItem from '../N8nMenuItem';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Menu',
component: N8nMenu,
argTypes: {
type: {
control: 'select',
options: ['primary', 'secondary'],
},
},
parameters: {
backgrounds: { default: '--color-background-xlight' },
},
};
const methods = {
onSelect: action('select'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
N8nMenuItem,
},
template:
`<n8n-menu v-bind="$props" @select="onSelect">
<n8n-menu-item index="1"> <span slot="title">Item 1</span> </n8n-menu-item>
<n8n-menu-item index="2"> <span slot="title">Item 2</span> </n8n-menu-item>
</n8n-menu>`,
methods,
});
export const Primary = Template.bind({});
Primary.parameters = {
backgrounds: { default: '--color-background-light' },
};
export const Secondary = Template.bind({});
Secondary.args = {
type: 'secondary',
};

View file

@ -0,0 +1,160 @@
import N8nMenu from './Menu.vue';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
import { StoryFn } from '@storybook/vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Menu',
component: N8nMenu,
argTypes: {
},
};
const methods = {
onSelect: action('select'),
};
const template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
},
template: `
<div style="height: 90vh; width: 200px">
<n8n-menu v-bind="$props" @select="onSelect"></n8n-menu>
</div>
`,
methods,
});
const templateWithHeaderAndFooter: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
N8nIcon,
N8nText,
},
template: `
<div style="height: 90vh; width: 200px">
<n8n-menu v-bind="$props" @select="onSelect">
<template #header>
<a href="#" class="p-m hideme" style="display: block;">
<n8n-icon icon="long-arrow-alt-left"/>&nbsp;&nbsp;Back to home
</a>
</template>
<template #footer>
<div class="p-m hideme">
<n8n-icon icon="user-circle" size="xlarge"/>&nbsp;&nbsp;
<n8n-text>John Smithson</n8n-text>
</div>
</template>
</n8n-menu>
</div>
`,
methods,
});
const templateWithAllSlots: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
N8nIcon,
N8nText,
},
template: `
<div style="height: 90vh; width: 200px">
<n8n-menu v-bind="$props" @select="onSelect">
<template #header>
<a href="#" class="p-m hideme" style="display: block;">
<n8n-icon icon="long-arrow-alt-left"/>&nbsp;&nbsp;Back to home
</a>
</template>
<template #menuPrefix>
<n8n-text class="hideme" size="small" color="text-light">Something can be added here...</n8n-text>
</template>
<template #menuSuffix>
<n8n-text class="hideme" size="small" color="text-light">...and here if needed</n8n-text>
</template>
<template #footer>
<div class="p-m hideme">
<n8n-icon icon="user-circle" size="xlarge"/>&nbsp;&nbsp;
<n8n-text>John Smithson</n8n-text>
</div>
</template>
</n8n-menu>
</div>
`,
methods,
});
const menuItems = [
{
id: 'workflows',
icon: 'network-wired',
label: 'Workflows',
position: 'top',
},
{
id: 'executions',
icon: 'tasks',
label: 'Executions',
position: 'top',
},
{
id: 'disabled-item',
icon: 'times',
label: 'Not Available',
available: false,
position: 'top',
},
{
id: 'website',
icon: 'globe',
label: 'Website',
type: 'link',
properties: {
href: 'https://www.n8n.io',
newWindow: true,
},
position: 'bottom',
},
{
id: 'help',
icon: 'question',
label: 'Help',
position: 'bottom',
children: [
{ icon: 'info', label: 'About n8n', id: 'about' },
{ icon: 'book', label: 'Documentation', id: 'docs' },
{
id: 'disabled-submenu-item',
icon: 'times',
label: 'Not Available',
available: false,
position: 'top',
},
{
id: 'quickstart',
icon: 'video',
label: 'Quickstart',
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true,
},
},
],
},
];
export const primary = template.bind({});
primary.args = {
items: menuItems,
};
export const withHeaderAndFooter = templateWithHeaderAndFooter.bind({});
withHeaderAndFooter.args = { items: menuItems };
export const withAllSlots = templateWithAllSlots.bind({});
withAllSlots.args = { items: menuItems };

View file

@ -1,22 +1,83 @@
<template>
<el-menu
:defaultActive="defaultActive"
:collapse="collapse"
:router="router"
:class="['n8n-menu', $style[type + (light ? '-light' : '')]]"
v-on="$listeners"
>
<slot></slot>
</el-menu>
<div :class="{
['menu-container']: true,
[$style.container]: true,
[$style.menuCollapsed]: collapsed
}">
<div v-if="$slots.header" :class="$style.menuHeader">
<slot name="header"></slot>
</div>
<div :class="$style.menuContent">
<div :class="{[$style.upperContent]: true, ['pt-xs']: $slots.menuPrefix }">
<div v-if="$slots.menuPrefix" :class="$style.menuPrefix">
<slot name="menuPrefix"></slot>
</div>
<el-menu
:defaultActive="defaultActive"
:collapse="collapsed"
v-on="$listeners"
>
<n8n-menu-item
v-for="item in upperMenuItems"
:key="item.id"
:item="item"
:compact="collapsed"
:popperClass="$style.submenuPopper"
:tooltipDelay="tooltipDelay"
:mode="mode"
:activeTab="activeTab"
@click="onSelect"
/>
</el-menu>
</div>
<div :class="{[$style.lowerContent]: true, ['pb-xs']: $slots.menuSuffix }">
<el-menu
:defaultActive="defaultActive"
:collapse="collapsed"
v-on="$listeners"
>
<n8n-menu-item
v-for="item in lowerMenuItems"
:key="item.id"
:item="item"
:compact="collapsed"
:popperClass="$style.submenuPopper"
:tooltipDelay="tooltipDelay"
:mode="mode"
:activeTab="activeTab"
@click="onSelect"
/>
</el-menu>
<div v-if="$slots.menuSuffix" :class="$style.menuSuffix">
<slot name="menuSuffix"></slot>
</div>
</div>
</div>
<div v-if="$slots.footer" :class="$style.menuFooter">
<slot name="footer"></slot>
</div>
</div>
</template>
<script lang="ts">
import ElMenu from 'element-ui/lib/menu';
import N8nMenuItem from '../N8nMenuItem';
import Vue from 'vue';
import Vue, { PropType } from 'vue';
import { Route } from 'vue-router';
import { IMenuItem } from '../../types';
export default Vue.extend({
name: 'n8n-menu',
components: {
ElMenu, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
N8nMenuItem,
},
data() {
return {
activeTab: '',
};
},
props: {
type: {
type: String,
@ -26,55 +87,96 @@ export default Vue.extend({
defaultActive: {
type: String,
},
collapse: {
collapsed: {
type: Boolean,
default: false,
},
light: {
type: Boolean,
mode: {
type: String,
default: 'router',
validator: (value: string): boolean => ['router', 'tabs'].includes(value),
},
router: {
type: Boolean,
tooltipDelay: {
type: Number,
default: 300,
},
items: {
type: Array as PropType<IMenuItem[]>,
},
},
components: {
ElMenu, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
mounted() {
if (this.mode === 'router') {
const found = this.items.find(item => {
return Array.isArray(item.activateOnRouteNames) && item.activateOnRouteNames.includes(this.$route.name || '') ||
Array.isArray(item.activateOnRoutePaths) && item.activateOnRoutePaths.includes(this.$route.path);
});
this.activeTab = found ? found.id : '';
} else {
this.activeTab = this.items.length > 0 ? this.items[0].id : '';
}
},
computed: {
upperMenuItems(): IMenuItem[] {
return this.items.filter((item: IMenuItem) => item.position === 'top' && item.available !== false);
},
lowerMenuItems(): IMenuItem[] {
return this.items.filter((item: IMenuItem) => item.position === 'bottom' && item.available !== false);
},
},
methods: {
onSelect(event: MouseEvent, option: string): void {
if (this.mode === 'tabs') {
this.activeTab = option;
}
this.$emit('select', option);
},
},
});
</script>
<style lang="scss" module>
.menu {
max-width: 200px;
.container {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-background-xlight);
}
.primary {
composes: menu;
--menu-item-hover-font-color: var(--color-primary);
}
.menuContent {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 1;
.secondary {
composes: menu;
--menu-font-color: var(--color-text-base);
--menu-item-font-weight: var(--font-weight-regular);
--menu-background-color: transparent;
--menu-item-active-font-color: var(--color-text-dark);
--menu-item-active-background-color: var(--color-foreground-base);
--menu-item-hover-font-color: var(--color-primary);
--menu-item-border-radius: 4px;
--menu-item-height: 38px;
li {
padding-left: 12px !important;
& > div > :global(.el-menu) {
background: none;
padding: 12px;
}
}
.secondary-light {
composes: secondary;
--menu-item-active-background-color: hsl(
var(--color-foreground-base-h),
var(--color-foreground-base-s),
var(--color-foreground-base-l),
0.7
);
.upperContent {
ul {
padding-top: 0 !important;
}
.submenuPopper {
bottom: auto !important;
top: 0 !important;
}
}
.lowerContent {
ul {
padding-bottom: 0 !important;
}
}
.menuCollapsed {
transition: width 150ms ease-in-out;
:global(.hideme) { display: none !important; }
}
.menuPrefix, .menuSuffix {
padding: var(--spacing-xs) var(--spacing-l);
}
</style>

View file

@ -0,0 +1,79 @@
import N8nMenuItem from ".";
import ElMenu from 'element-ui/lib/menu';
import { StoryFn } from '@storybook/vue';
export default {
title: 'Atoms/MenuItem',
component: N8nMenuItem,
};
const template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
ElMenu, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
N8nMenuItem ,
},
template: `
<div style="width: 200px">
<el-menu>
<n8n-menu-item v-bind="$props" />
</el-menu>
</div>
`,
});
export const defaultMenuItem = template.bind({});
defaultMenuItem.args = {
item: {
id: 'workflows',
icon: 'heart',
label: 'Workflows',
},
};
export const compact = template.bind({});
compact.args = {
item: {
id: 'compact',
icon: 'ice-cream',
label: 'Click here',
},
compact: true,
};
export const link = template.bind({});
link.args = {
item: {
id: 'website',
icon: 'globe',
label: 'Website',
type: 'link',
properties: {
href: 'https://www.n8n.io',
newWindow: true,
},
},
};
export const withChildren = template.bind({});
withChildren.args = {
item: {
id: 'help',
icon: 'question',
label: 'Help',
children: [
{ icon: 'info', label: 'About n8n', id: 'about' },
{ icon: 'book', label: 'Documentation', id: 'docs' },
{
id: 'quickstart',
icon: 'video',
label: 'Quickstart',
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true,
},
},
],
},
};

View file

@ -1,7 +1,272 @@
<template>
<el-submenu
v-if="item.children && item.children.length > 0"
:id="item.id"
:class="{
[$style.submenu]: true,
[$style.item]: true,
[$style.compact]: compact,
[$style.active]: mode === 'router' && isItemActive(item)
}"
:index="item.id"
:popper-append-to-body="false"
:popper-class="`${$style.submenuPopper} ${popperClass}`"
>
<template slot="title">
<n8n-icon v-if="item.icon" :class="$style.icon" :icon="item.icon" :size="item.customIconSize || 'large'" />
<span :class="$style.label">{{ item.label }}</span>
</template>
<el-menu-item
v-for="child in availableChildren"
:key="child.id"
:id="child.id"
:class="{
[$style.menuItem]: true,
[$style.disableActiveStyle]: !isItemActive(child),
[$style.active]: isItemActive(child),
}"
:index="child.id"
@click="onItemClick(child)"
>
<n8n-icon v-if="child.icon" :class="$style.icon" :icon="child.icon" />
<span :class="$style.label">{{ child.label }}</span>
</el-menu-item>
</el-submenu>
<n8n-tooltip v-else placement="right" :content="item.label" :disabled="!compact" :open-delay="tooltipDelay">
<el-menu-item
:id="item.id"
:class="{
[$style.menuItem]: true,
[$style.item]: true,
[$style.disableActiveStyle]: !isItemActive(item),
[$style.active]: isItemActive(item),
[$style.compact]: compact
}"
:index="item.id"
@click="onItemClick(item)"
>
<n8n-icon v-if="item.icon" :class="$style.icon" :icon="item.icon" :size="item.customIconSize || 'large'" />
<span :class="$style.label">{{ item.label }}</span>
</el-menu-item>
</n8n-tooltip>
</template>
<script lang="ts">
import ElSubmenu from 'element-ui/lib/submenu';
import ElMenuItem from 'element-ui/lib/menu-item';
import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon';
import { IMenuItem } from '../../types';
import Vue from 'vue';
import { Route } from 'vue-router';
ElMenuItem.name = 'n8n-menu-item'; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
export default Vue.extend({
name: 'n8n-menu-item',
components: {
ElSubmenu, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
ElMenuItem, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
N8nIcon,
N8nTooltip,
},
props: {
item: {
type: Object as () => IMenuItem,
required: true,
},
compact: {
type: Boolean,
default: false,
},
tooltipDelay: {
type: Number,
default: 300,
},
popperClass: {
type: String,
default: '',
},
mode: {
type: String,
default: 'router',
validator: (value: string): boolean => ['router', 'tabs'].includes(value),
},
activeTab: {
type: String,
},
},
computed: {
availableChildren(): IMenuItem[] {
return Array.isArray(this.item.children) ? this.item.children.filter(child => child.available !== false) : [];
},
},
methods: {
isItemActive(item: IMenuItem): boolean {
const isItemActive = this.isActive(item);
const hasActiveChild = Array.isArray(item.children) && item.children.some(child => this.isActive(child));
return isItemActive || hasActiveChild;
},
isActive(item: IMenuItem): boolean {
if (this.mode === 'router') {
if (item.activateOnRoutePaths) {
return Array.isArray(item.activateOnRoutePaths) && item.activateOnRoutePaths.includes(this.$route.path);
} else if (item.activateOnRouteNames) {
return Array.isArray(item.activateOnRouteNames) && item.activateOnRouteNames.includes(this.$route.name || '');
}
return false;
} else {
return item.id === this.activeTab;
}
},
onItemClick(item: IMenuItem, event: MouseEvent) {
if (item && item.type === 'link' && item.properties) {
const href: string = item.properties.href;
if (!href) {
return;
}
if (item.properties.newWindow) {
window.open(href);
}
else {
window.location.assign(item.properties.href);
}
}
this.$emit('click', event, item.id);
},
},
});
export default ElMenuItem;
</script>
<style module lang="scss">
// Element menu-item overrides
:global(.el-menu-item), :global(.el-submenu__title) {
--menu-font-color: var(--color-text-base);
--menu-item-active-background-color: var(--color-foreground-base);
--menu-item-active-font-color: var(--color-text-dark);
--menu-item-hover-fill: var(--color-foreground-base);
--menu-item-hover-font-color: var(--color-text-dark);
--menu-item-height: 35px;
--submenu-item-height: 27px;
}
.submenu {
:global(.el-submenu__title) {
display: flex;
align-items: center;
border-radius: var(--border-radius-base);
padding: var(--spacing-2xs) var(--spacing-xs) !important;
user-select: none;
i {
padding-top: 2px;
&:hover {
color: var(--color-primary);
}
}
&:hover {
.icon { color: var(--color-text-dark) }
}
}
.menuItem {
height: var(--submenu-item-height) !important;
min-width: auto !important;
margin: var(--spacing-2xs) 0 !important;
padding-left: var(--spacing-l) !important;
user-select: none;
&:hover {
.icon { color: var(--color-text-dark) }
}
};
}
.disableActiveStyle {
background-color: initial !important;
color: var(--color-text-base) !important;
svg {
color: var(--color-text-base) !important;
}
&:hover {
background-color: var(--color-foreground-base) !important;
svg {
color: var(--color-text-dark) !important;
}
&:global(.el-submenu) {
background-color: unset !important;
}
}
}
.active {
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
.icon { color: var(--color-text-dark) }
}
.menuItem {
display: flex;
padding: var(--spacing-2xs) var(--spacing-xs) !important;
margin: 0 !important;
border-radius: var(--border-radius-base) !important;
}
.icon {
min-width: var(--spacing-s);
margin-right: var(--spacing-xs);
text-align: center;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.item + .item {
margin-top: 8px !important;
}
.compact {
width: 40px;
.icon {
margin: 0;
overflow: visible !important;
visibility: visible !important;
width: initial !important;
height: initial !important;
}
.label {
display: none;
}
}
.submenuPopper {
display: block;
left: 40px !important;
bottom: 110px !important;
top: auto !important;
ul {
padding: 0 var(--spacing-xs) !important;
}
.menuItem {
display: flex;
padding: var(--spacing-2xs) var(--spacing-xs) !important;
}
.icon {
margin-right: var(--spacing-xs);
}
.label {
display: block;
}
}
</style>

View file

@ -1,3 +1,4 @@
export * from './form';
export * from './user';
export * from './menu';
export * from './button';

View file

@ -0,0 +1,21 @@
export type IMenuItem = {
id: string;
label: string;
icon?: string;
customIconSize?: 'medium' | 'small';
available?: boolean;
position?: 'top' | 'bottom';
type?: 'default' | 'link';
properties?: ILinkMenuItemProperties;
// For router menus populate only one of those arrays:
// If menu item can be activated on certain route names (easy mode)
activateOnRouteNames?: string[],
// For more specific matching, we can use paths
activateOnRoutePaths?: string[],
children?: IMenuItem[],
};
export type ILinkMenuItemProperties = {
href: string;
newWindow?: boolean;
};

View file

@ -754,23 +754,6 @@ export interface ITimeoutHMS {
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export type MenuItemType = 'link';
export type MenuItemPosition = 'top' | 'bottom';
export interface IMenuItem {
id: string;
type: MenuItemType;
position?: MenuItemPosition;
properties: ILinkMenuItemProperties;
}
export interface ILinkMenuItemProperties {
title: string;
icon: string;
href: string;
newWindow?: boolean;
}
export interface ISubcategoryItemProps {
subcategory: string;
description: string;

View file

@ -49,34 +49,7 @@
<template slot="content">
<div :class="$style.container">
<div :class="$style.sidebar">
<n8n-menu
type="secondary"
@select="onTabSelect"
defaultActive="connection"
:light="true"
>
<n8n-menu-item index="connection">
<span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.connection') }}</span>
</n8n-menu-item>
<enterprise-edition v-if="credentialType" :features="[EnterpriseEditionFeature.Sharing]">
<n8n-menu-item index="sharing">
<span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.sharing') }}</span>
</n8n-menu-item>
<template #fallback>
<n8n-menu-item
v-for="fakeDoor in credentialsFakeDoorFeatures"
v-bind:key="fakeDoor.featureName"
:index="`coming-soon/${fakeDoor.id}`"
:class="$style.tab"
>
<span slot="title">{{ $locale.baseText(fakeDoor.featureName) }}</span>
</n8n-menu-item>
</template>
</enterprise-edition>
<n8n-menu-item v-if="credentialType" index="details">
<span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.details') }}</span>
</n8n-menu-item>
</n8n-menu>
<n8n-menu mode="tabs" :items="sidebarItems" @select="onTabSelect" ></n8n-menu>
</div>
<div v-if="activeTab === 'connection'" :class="$style.mainContent" ref="content">
<CredentialConfig
@ -170,6 +143,8 @@ import {IDataObject} from "n8n-workflow";
import FeatureComingSoon from '../FeatureComingSoon.vue';
import {mapGetters} from "vuex";
import {getCredentialPermissions, IPermissions} from "@/permissions";
import { IMenuItem } from 'n8n-design-system';
import { BaseTextKey } from '@/plugins/i18n';
interface NodeAccessMap {
[nodeType: string]: ICredentialNodeAccess | null;
@ -421,6 +396,43 @@ export default mixins(showMessage, nodeHelpers).extend({
return getCredentialPermissions(this.currentUser, (this.credentialId ? this.currentCredential : this.credentialData) as ICredentialsResponse, this.$store);
},
sidebarItems(): IMenuItem[] {
const items: IMenuItem[] = [
{
id: 'connection',
label: this.$locale.baseText('credentialEdit.credentialEdit.connection'),
position: 'top',
},
{
id: 'sharing',
label: this.$locale.baseText('credentialEdit.credentialEdit.sharing'),
position: 'top',
available: this.credentialType !== null && this.isSharingAvailable,
},
];
if (this.credentialType !== null && !this.isSharingAvailable) {
for (const item of this.credentialsFakeDoorFeatures) {
items.push({
id: `coming-soon/${item.id}`,
label: this.$locale.baseText(item.featureName as BaseTextKey),
position: 'top',
});
}
}
items.push(
{
id: 'details',
label: this.$locale.baseText('credentialEdit.credentialEdit.details'),
position: 'top',
},
);
return items;
},
isSharingAvailable(): boolean {
return this.$store.getters['settings/isEnterpriseFeatureEnabled'](EnterpriseEditionFeature.Sharing) === true;
},
},
methods: {
async beforeClose() {
@ -458,7 +470,6 @@ export default mixins(showMessage, nodeHelpers).extend({
return false;
},
displayCredentialParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
@ -979,6 +990,10 @@ export default mixins(showMessage, nodeHelpers).extend({
min-width: 170px;
margin-right: var(--spacing-l);
flex-grow: 1;
ul {
padding: 0 !important;
}
}
.header {

View file

@ -4,132 +4,51 @@
[$style.sideMenu]: true,
[$style.sideMenuCollapsed]: isCollapsed
}">
<div :class="{[$style.sideMenuWrapper]: true, [$style.expanded]: !isCollapsed}">
<div
id="collapse-change-button"
:class="{
['clickable']: true,
[$style.sideMenuCollapseButton]: true,
[$style.expandedButton]: !isCollapsed
}"
@click="toggleCollapse"
></div>
<n8n-menu :default-active="$route.path" @select="handleSelect" :collapse="isCollapsed">
<n8n-menu-item
index="logo"
:class="[$style.logoItem, $style.disableActiveStyle]"
>
<div
id="collapse-change-button"
:class="{ ['clickable']: true, [$style.sideMenuCollapseButton]: true, [$style.expandedButton]: !isCollapsed }"
@click="toggleCollapse">
</div>
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<template #header>
<div :class="$style.logo">
<img :src="basePath + (isCollapsed ? 'n8n-logo-collapsed.svg' : 'n8n-logo-expanded.svg')" :class="$style.icon" alt="n8n"/>
</n8n-menu-item>
<div :class="$style.sideMenuFlexContainer">
<div :class="$style.sideMenuUpper">
<MenuItemsIterator :items="sidebarMenuTopItems" :root="true"/>
<el-submenu
index="/workflow"
title="Workflow"
popperClass="sidebar-popper"
:class="{
[$style.workflowSubmenu]: true,
[$style.active]: $route.path === '/workflow'
}"
>
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.workflows') }}</span>
</template>
<n8n-menu-item index="/workflow">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-open" :class="$style.disableActiveStyle">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
<n8n-menu-item v-if="isTemplatesEnabled" index="/templates" :class="$style.templatesSubmenu">
<font-awesome-icon icon="box-open"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.templates') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/credentials" :class="$style.credentialsSubmenu">
<font-awesome-icon icon="key"/>
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.credentials') }}</span>
</n8n-menu-item>
<n8n-menu-item index="executions" :class="[$style.disableActiveStyle, $style.executionsSubmenu]">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.executions') }}</span>
</n8n-menu-item>
</div>
<div :class="$style.sideMenuLower">
<n8n-menu-item index="settings" v-if="canUserAccessSettings && currentUser" :class="$style.settingsSubmenu">
<font-awesome-icon icon="cog"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('settings') }}</span>
</n8n-menu-item>
<el-submenu index="help" :class="[$style.helpMenu, $style.disableActiveStyle]" title="Help" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="question"/>&nbsp;
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.help') }}</span>
</template>
<MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" />
<n8n-menu-item index="help-about">
<template slot="title">
<font-awesome-icon :class="$style['about-icon']" icon="info"/>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.aboutN8n') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
<MenuItemsIterator :items="sidebarMenuBottomItems" :root="true"/>
<div :class="{
[$style.footerMenuItems] : true,
[$style.loggedIn]: showUserArea,
}">
<n8n-menu-item index="updates" :class="$style.updatesSubmenu" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<div :class="$style.giftContainer">
<GiftNotificationIcon />
</div>
<span slot="title" :class="['item-title-root', $style.updatesLabel]">
{{nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : ''}}
</span>
</n8n-menu-item>
<n8n-menu-item v-if="showUserArea" :class="$style.userSubmenu" index="">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown :disabled="!isCollapsed" placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{[$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar :firstName="currentUser.firstName" :lastName="currentUser.lastName" size="small" />
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="settings">{{ $locale.baseText('settings') }}</el-dropdown-item>
<el-dropdown-item command="logout">{{ $locale.baseText('auth.signout') }}</el-dropdown-item>
</el-dropdown-menu>
</div>
</el-dropdown>
<div slot="title" :class="['item-title-root', $style.username ]" v-if="!isCollapsed">
<span :title="currentUser.fullName" :class="$style.fullName">{{currentUser.fullName}}</span>
<div :class="{[$style.userActions]: true, ['user-actions']: true }">
<n8n-action-dropdown :items="userMenuItems" placement="top-start" @select="onUserActionToggle" />
</div>
</div>
</n8n-menu-item>
</div>
</template>
<template #menuSuffix v-if="hasVersionUpdates">
<div :class="$style.updates" @click="openUpdatesPanel">
<div :class="$style.giftContainer">
<GiftNotificationIcon />
</div>
<n8n-text :class="{['ml-xs']: true, [$style.expanded]: fullyExpanded }" color="text-base">
{{ nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : '' }}
</n8n-text>
</div>
</template>
<template #footer v-if="showUserArea">
<div :class="$style.userArea">
<div class="ml-3xs">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown :disabled="!isCollapsed" placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{[$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar :firstName="currentUser.firstName" :lastName="currentUser.lastName" size="small" />
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="settings">{{ $locale.baseText('settings') }}</el-dropdown-item>
<el-dropdown-item command="logout">{{ $locale.baseText('auth.signout') }}</el-dropdown-item>
</el-dropdown-menu>
</div>
</el-dropdown>
</div>
<div :class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }">
<n8n-text size="small" :bold="true" color="text-dark">{{currentUser.fullName}}</n8n-text>
</div>
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
<n8n-action-dropdown :items="userMenuItems" placement="top-start" @select="onUserActionToggle" />
</div>
</div>
</n8n-menu>
</div>
</template>
</n8n-menu>
</div>
</template>
<script lang="ts">
@ -151,7 +70,6 @@ import { workflowRun } from '@/components/mixins/workflowRun';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import MenuItemsIterator from './MenuItemsIterator.vue';
import {
MODAL_CANCEL,
MODAL_CLOSE,
@ -181,22 +99,12 @@ export default mixins(
ExecutionsList,
GiftNotificationIcon,
WorkflowSettings,
MenuItemsIterator,
},
data () {
return {
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
userMenuItems: [
{
id: 'settings',
label: this.$locale.baseText('settings'),
},
{
id: 'logout',
label: this.$locale.baseText('auth.signout'),
},
],
fullyExpanded: false,
};
},
computed: {
@ -223,61 +131,154 @@ export default mixins(
showUserArea(): boolean {
return this.isUserManagementEnabled && this.canUserAccessSidebarUserInfo && this.currentUser;
},
helpMenuItems (): object[] {
return [
{
id: 'quickstart',
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
title: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'),
icon: 'video',
newWindow: true,
},
},
{
id: 'docs',
type: 'link',
properties: {
href: 'https://docs.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'),
icon: 'book',
newWindow: true,
},
},
{
id: 'forum',
type: 'link',
properties: {
href: 'https://community.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.forum'),
icon: 'users',
newWindow: true,
},
},
{
id: 'examples',
type: 'link',
properties: {
href: 'https://docs.n8n.io/courses',
title: this.$locale.baseText('mainSidebar.helpMenuItems.course'),
icon: 'graduation-cap',
newWindow: true,
},
},
];
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
sidebarMenuTopItems(): IMenuItem[] {
return this.$store.getters.sidebarMenuItems.filter((item: IMenuItem) => item.position === 'top');
userMenuItems (): object[] {
return [
{
id: 'settings',
label: this.$locale.baseText('settings'),
},
{
id: 'logout',
label: this.$locale.baseText('auth.signout'),
},
];
},
sidebarMenuBottomItems(): IMenuItem[] {
return this.$store.getters.sidebarMenuItems.filter((item: IMenuItem) => item.position === 'bottom');
mainMenuItems (): IMenuItem[] {
const items: IMenuItem[] = [];
const injectedItems = this.$store.getters.sidebarMenuItems as IMenuItem[];
if (injectedItems && injectedItems.length > 0) {
for(const item of injectedItems) {
items.push(
{
id: item.id,
// @ts-ignore
icon: item.properties ? item.properties.icon : '',
// @ts-ignore
label: item.properties ? item.properties.title : '',
position: item.position,
activateOnRouteNames: [ VIEWS.TEMPLATES ],
type: item.properties?.href ? 'link' : 'regular',
properties: item.properties,
} as IMenuItem,
);
}
};
const regularItems: IMenuItem[] = [
{
id: 'workflows',
icon: 'network-wired',
label: this.$locale.baseText('mainSidebar.workflows'),
position: 'top',
activateOnRouteNames: [ VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOWS, VIEWS.WORKFLOW ],
children: [
{
id: 'workflow',
label: this.$locale.baseText('mainSidebar.new'),
icon: 'file',
activateOnRouteNames: [ VIEWS.NEW_WORKFLOW ],
},
{
id: 'workflow-open',
label: this.$locale.baseText('mainSidebar.open'),
icon: 'folder-open',
},
],
},
{
id: 'templates',
icon: 'box-open',
label: this.$locale.baseText('mainSidebar.templates'),
position: 'top',
available: this.isTemplatesEnabled,
activateOnRouteNames: [ VIEWS.TEMPLATES ],
},
{
id: 'credentials',
icon: 'key',
label: this.$locale.baseText('mainSidebar.credentials'),
customIconSize: 'medium',
position: 'top',
activateOnRouteNames: [ VIEWS.CREDENTIALS ],
},
{
id: 'executions',
icon: 'tasks',
label: this.$locale.baseText('mainSidebar.executions'),
position: 'top',
},
{
id: 'settings',
icon: 'cog',
label: this.$locale.baseText('settings'),
position: 'bottom',
available: this.canUserAccessSettings && this.currentUser,
activateOnRouteNames: [ VIEWS.USERS_SETTINGS, VIEWS.API_SETTINGS, VIEWS.PERSONAL_SETTINGS ],
},
{
id: 'help',
icon: 'question',
label: 'Help',
position: 'bottom',
children: [
{
id: 'quickstart',
icon: 'video',
label: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'),
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true,
},
},
{
id: 'docs',
icon: 'book',
label: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'),
type: 'link',
properties: {
href: 'https://docs.n8n.io',
newWindow: true,
},
},
{
id: 'forum',
icon: 'users',
label: this.$locale.baseText('mainSidebar.helpMenuItems.forum'),
type: 'link',
properties: {
href: 'https://community.n8n.io',
newWindow: true,
},
},
{
id: 'examples',
icon: 'graduation-cap',
label: this.$locale.baseText('mainSidebar.helpMenuItems.course'),
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true,
},
},
{
id: 'about',
icon: 'info',
label: this.$locale.baseText('mainSidebar.aboutN8n'),
position: 'bottom',
},
],
},
];
return [ ...items, ...regularItems ];
},
},
mounted() {
this.fullyExpanded = !this.isCollapsed;
if (this.$refs.user) {
this.$externalHooks().run('mainSidebar.mounted', { userRef: this.$refs.user });
}
@ -320,13 +321,21 @@ export default mixins(
},
toggleCollapse () {
this.$store.commit('ui/toggleSidebarMenuCollapse');
// When expanding, delay showing some element to ensure smooth animation
if (!this.isCollapsed) {
setTimeout(() => {
this.fullyExpanded = !this.isCollapsed;
}, 300);
} else {
this.fullyExpanded = !this.isCollapsed;
}
},
openUpdatesPanel() {
this.$store.dispatch('ui/openModal', VERSIONS_MODAL_KEY);
},
async handleSelect (key: string) {
switch (key) {
case '/workflow': {
case 'workflow': {
await this.createNewWorkflow();
break;
}
@ -334,14 +343,16 @@ export default mixins(
this.$store.dispatch('ui/openModal', WORKFLOW_OPEN_MODAL_KEY);
break;
}
case '/templates': {
case 'templates': {
if (this.$router.currentRoute.name !== VIEWS.TEMPLATES) {
this.$router.push({ name: VIEWS.TEMPLATES });
}
break;
}
case '/credentials': {
this.$router.push({name: VIEWS.CREDENTIALS});
case 'credentials': {
if (this.$router.currentRoute.name !== VIEWS.CREDENTIALS) {
this.$router.push({name: VIEWS.CREDENTIALS});
}
break;
}
case 'executions': {
@ -352,15 +363,24 @@ export default mixins(
const defaultRoute = this.findFirstAccessibleSettingsRoute();
if (defaultRoute) {
const routeProps = this.$router.resolve({ name: defaultRoute });
this.$router.push(routeProps.route.path);
if (this.$router.currentRoute.name !== defaultRoute) {
this.$router.push(routeProps.route.path);
}
}
break;
}
case 'help-about': {
case 'about': {
this.trackHelpItemClick('about');
this.$store.dispatch('ui/openModal', ABOUT_MODAL_KEY);
break;
}
case 'quickstart':
case 'docs':
case 'forum':
case 'examples' : {
this.trackHelpItemClick(key);
break;
}
default: break;
}
},
@ -444,162 +464,34 @@ export default mixins(
});
</script>
<style lang="scss">
#side-menu {
.el-menu {
--menu-item-active-background-color: var(--color-foreground-base);
--menu-item-active-font-color: var(--color-text-dark);
--menu-item-hover-fill: var(--color-foreground-base);
--menu-item-hover-font-color: var(--color-text-dark);
--menu-item-height: 35px;
--submenu-item-height: 27px;
.el-icon-arrow-down {
font-weight: bold;
right: 12px;
&:hover {
color: var(--color-primary);
}
}
.el-menu-item:hover, .el-submenu__title:hover, .el-menu-item.is-active {
svg {
color: var(--color-text-dark);
}
}
.el-menu-item, .el-menu-item .el-tooltip, .el-submenu__title {
padding: 0 8px !important;
}
.el-menu-item, .el-submenu__title {
margin: 8px 0;
border-radius: var(--border-radius-base);
user-select: none;
.item-title-root {
position: absolute;
left: 45px;
}
svg {
color: var(--color-text-light);
}
}
.el-submenu {
.el-menu-item {
height: var(--menu-item-height);
line-height: var(--menu-item-height);
padding-left: var(--spacing-l) !important;
min-width: auto;
svg {
width: var(--spacing-m);
}
}
.el-menu .el-menu-item {
height: var(--submenu-item-height);
margin: 4px 0 !important;
.item-title {
position: absolute;
left: 60px;
}
}
}
}
}
.sidebar-popper{
.el-menu-item {
--menu-item-height: 35px;
--submenu-item-height: 27px;
--menu-item-hover-fill: var(--color-foreground-base);
border-radius: var(--border-radius-base);
margin: 0 var(--spacing-2xs);
.item-title {
position: absolute;
left: 55px;
}
.svg-inline--fa {
color: var(--color-text-light);
position: relative;
right: -3px;
}
&:hover {
.svg-inline--fa {
color: var(--color-text-dark);
}
}
}
}
</style>
<style lang="scss" module>
.sideMenu {
height: 100%;
&.sideMenuCollapsed {
.fullName,
:global(.item-title-root),
:global(.el-menu--collapse) :global(.el-submenu__icon-arrow),
:global(.el-icon-arrow-down) {
display: none;
}
.active {
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
svg { color: var(--color-text-dark) !important; }
}
.userSubmenu::before {
width: 160%;
}
}
svg:global(.svg-inline--fa) {
position: relative;
left: 3px;
}
svg:global(.svg-inline--fa.fa-home) { left: 4px; }
.executionsSubmenu svg:global(.svg-inline--fa),
.credentialsSubmenu svg:global(.svg-inline--fa),
.updatesSubmenu svg:global(.svg-inline--fa),
.settingsSubmenu svg:global(.svg-inline--fa)
{ left: 5px !important; }
.helpMenu{
svg:global(.svg-inline--fa) { left: 6px !important; }
:global(.el-menu--inline) { margin-bottom: 8px; }
}
}
.sideMenuWrapper {
position: relative;
height: 100%;
width: $sidebar-width;
border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
transition: width 150ms ease-in-out;
width: $sidebar-expanded-width;
.logo {
height: $header-height;
display: flex;
align-items: center;
padding: var(--spacing-xs);
&.expanded {
width: $sidebar-expanded-width;
.icon {
img {
position: relative;
left: 16px;
left: 1px;
height: 20px;
}
}
ul { height: 100%; }
&.sideMenuCollapsed {
width: $sidebar-width;
.logo img {
left: 0;
}
}
}
.sideMenuCollapseButton {
@ -645,167 +537,60 @@ export default mixins(
}
}
.sideMenuFlexContainer {
.updates {
display: flex;
flex-direction: column;
justify-content: space-between;
height: calc(100% - $header-height);
padding: var(--spacing-5xs) 0;
}
align-items: center;
height: 26px;
cursor: pointer;
.sideMenuUpper, .sideMenuLower {
padding: 0 var(--spacing-xs);
}
svg { color: var(--color-text-base) !important; }
span {
display: none;
&.expanded { display: initial; }
}
li:global(.is-active) {
.sideMenuLower &, &.disableActiveStyle {
background-color: initial;
color: var(--color-text-base);
svg {
color: var(--color-text-base) !important;
}
&:hover {
background-color: var(--color-foreground-base);
svg {
color: var(--color-text-dark) !important;
}
&:global(.el-submenu) {
background-color: unset;
}
&:hover {
&, & svg {
color: var(--color-text-dark) !important;
}
}
}
.logoItem {
.userArea {
display: flex;
justify-content: space-between;
height: $header-height;
line-height: $header-height;
margin: 0 !important;
border-radius: 0 !important;
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-background-xlight);
cursor: default;
padding: var(--spacing-xs);
align-items: center;
height: 60px;
border-top: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
&:hover, &:global(.is-active):hover {
background-color: initial !important;
}
.userName {
display: none;
overflow: hidden;
width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
* { vertical-align: middle; }
.icon {
height: 18px;
position: relative;
left: 6px;
}
}
.footerMenuItems {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: flex-end;
padding-bottom: var(-spacing-m);
&.loggedIn {
padding-bottom: var(--spacing-xs);
}
}
.aboutIcon {
margin-left: 5px;
}
.updatesSubmenu {
margin-top: 0 !important;
.updatesLabel {
font-size: var(--font-size-xs);
}
.giftContainer {
display: flex;
justify-content: flex-start;
align-items: center;
height: 100%;
width: 100%;
div > div { right: -.5em; }
}
}
.userSubmenu {
position: relative;
cursor: default;
padding: var(--spacing-xs) !important;
margin: 0 !important;
// Fake border-top on user area
&::before {
width: 114%;
border-top: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
content: "";
position: absolute;
top: 0;
left: -12px;
}
&:hover, &:global(.is-active) {
background-color: initial !important;
.userActions svg {
color: var(--color-text-base) !important;
&.expanded {
display: initial;
}
}
.avatar {
position: relative;
left: -2px;
display: flex;
align-items: center;
justify-content: center;
padding-top: 16px;
cursor: default;
}
.username {
position: relative !important;
display: flex !important;
left: 9px !important;
justify-content: space-between;
align-items: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
padding-top: var(--spacing-s);
cursor: default;
.fullName {
width: 99px;
span {
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-dark);
}
}
.userActions {
position: relative;
left: -1px;
cursor: pointer;
display: none;
&:hover {
svg { color: initial; }
&.expanded {
display: initial;
}
}
}
.userActionsMenu {
margin-left: 25px !important;
}
@media screen and (max-height: 470px) {
.helpMenu { display: none; }
:global(#help) { display: none; }
}
</style>

View file

@ -1,49 +0,0 @@
<template>
<div>
<n8n-menu-item
v-for="item in items"
:key="item.id"
:index="item.id"
@click="onClick(item)"
>
<font-awesome-icon :icon="item.properties.icon" />
<span slot="title" :class="{'item-title-root': root, 'item-title': !root}">{{ item.properties.title }}</span>
</n8n-menu-item>
</div>
</template>
<script lang="ts">
import { IMenuItem } from '../Interface';
import Vue from 'vue';
export default Vue.extend({
name: 'MenuItemsIterator',
props: [
'items',
'root',
'afterItemClick',
],
methods: {
onClick(item: IMenuItem) {
if (item && item.type === 'link' && item.properties) {
const href = item.properties.href;
if (!href) {
return;
}
if (item.properties.newWindow) {
window.open(href);
}
else {
window.location.assign(item.properties.href);
}
if(this.afterItemClick) {
this.afterItemClick(item.id);
}
}
},
},
});
</script>

View file

@ -1,53 +1,22 @@
<template>
<div :class="$style.container">
<n8n-menu :router="true" :default-active="$route.path" type="secondary">
<div :class="$style.returnButton" @click="onReturn">
<i :class="$style.icon">
<font-awesome-icon icon="arrow-left" />
</i>
<n8n-heading slot="title" size="large" :class="$style.settingsHeading" :bold="true">{{ $locale.baseText('settings') }}</n8n-heading>
</div>
<n8n-menu-item index="/settings/personal" v-if="canAccessPersonalSettings()" :class="$style.tab">
<i :class="$style.icon">
<font-awesome-icon icon="user-circle" />
</i>
<span slot="title">{{ $locale.baseText('settings.personal') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/users" v-if="canAccessUsersSettings()" :class="[$style.tab, $style.usersMenu]">
<i :class="$style.icon">
<font-awesome-icon icon="user-friends" />
</i>
<span slot="title">{{ $locale.baseText('settings.users') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/api" v-if="canAccessApiSettings()" :class="[$style.tab, $style.apiMenu]">
<i :class="$style.icon">
<font-awesome-icon icon="plug" />
</i>
<span slot="title">{{ $locale.baseText('settings.n8napi') }}</span>
</n8n-menu-item>
<n8n-menu-item
v-for="fakeDoor in settingsFakeDoorFeatures"
v-bind:key="fakeDoor.featureName"
:index="`/settings/coming-soon/${fakeDoor.id}`"
:class="$style.tab"
>
<i :class="$style.icon">
<font-awesome-icon :icon="fakeDoor.icon" />
</i>
<span slot="title">{{ $locale.baseText(fakeDoor.featureName) }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/community-nodes" v-if="canAccessCommunityNodes()" :class="$style.tab">
<i :class="$style.icon">
<font-awesome-icon icon="cube" />
</i>
<span slot="title">{{ $locale.baseText('settings.communityNodes') }}</span>
</n8n-menu-item>
<n8n-menu :items="sidebarMenuItems" @select="handleSelect">
<template #header>
<div :class="$style.returnButton" @click="onReturn">
<i class="mr-xs">
<font-awesome-icon icon="arrow-left" />
</i>
<n8n-heading slot="title" size="large" :class="$style.settingsHeading" :bold="true">{{ $locale.baseText('settings') }}</n8n-heading>
</div>
</template>
<template #menuSuffix>
<div :class="$style.versionContainer">
<n8n-link @click="onVersionClick" size="small">
{{ $locale.baseText('settings.version') }} {{ versionCli }}
</n8n-link>
</div>
</template>
</n8n-menu>
<div :class="$style.versionContainer">
<n8n-link @click="onVersionClick" size="small">
{{ $locale.baseText('settings.version') }} {{ versionCli }}
</n8n-link>
</div>
</div>
</template>
@ -59,6 +28,8 @@ import { userHelpers } from './mixins/userHelpers';
import { pushConnection } from "@/components/mixins/pushConnection";
import { IFakeDoor } from '@/Interface';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
import { IMenuItem } from 'n8n-design-system';
import { BaseTextKey } from '@/plugins/i18n';
export default mixins(
userHelpers,
@ -73,6 +44,64 @@ export default mixins(
settingsFakeDoorFeatures(): IFakeDoor[] {
return this.$store.getters['ui/getFakeDoorByLocation']('settings');
},
sidebarMenuItems(): IMenuItem[] {
const menuItems: IMenuItem[] = [
{
id: 'settings-personal',
icon: 'user-circle',
label: this.$locale.baseText('settings.personal'),
position: 'top',
available: this.canAccessPersonalSettings(),
activateOnRouteNames: [ VIEWS.PERSONAL_SETTINGS ],
},
{
id: 'settings-users',
icon: 'user-friends',
label: this.$locale.baseText('settings.users'),
position: 'top',
available: this.canAccessUsersSettings(),
activateOnRouteNames: [ VIEWS.USERS_SETTINGS ],
},
{
id: 'settings-api',
icon: 'plug',
label: this.$locale.baseText('settings.n8napi'),
position: 'top',
available: this.canAccessApiSettings(),
activateOnRouteNames: [ VIEWS.API_SETTINGS ],
},
];
for (const item of this.settingsFakeDoorFeatures) {
if (item.uiLocations.includes('settings')) {
menuItems.push({
id: item.id,
icon: item.icon || 'question',
label: this.$locale.baseText(item.featureName as BaseTextKey),
position: 'top',
available: true,
activateOnRoutePaths: [ `/settings/coming-soon/${item.id}` ],
});
}
}
menuItems.push(
{
id: 'settings-community-nodes',
icon: 'cube',
label: this.$locale.baseText('settings.communityNodes'),
position: 'top',
available: this.canAccessCommunityNodes(),
activateOnRouteNames: [ VIEWS.COMMUNITY_NODES ],
},
);
return menuItems;
},
},
mounted() {
this.pushConnect();
},
methods: {
canAccessPersonalSettings(): boolean {
@ -96,83 +125,60 @@ export default mixins(
openUpdatesPanel() {
this.$store.dispatch('ui/openModal', VERSIONS_MODAL_KEY);
},
},
mounted() {
this.pushConnect();
async handleSelect (key: string) {
switch (key) {
case 'settings-personal':
if (this.$router.currentRoute.name !== VIEWS.PERSONAL_SETTINGS) {
this.$router.push({ name: VIEWS.PERSONAL_SETTINGS });
}
break;
case 'settings-users':
if (this.$router.currentRoute.name !== VIEWS.USERS_SETTINGS) {
this.$router.push({ name: VIEWS.USERS_SETTINGS });
}
break;
case 'settings-api':
if (this.$router.currentRoute.name !== VIEWS.API_SETTINGS) {
this.$router.push({ name: VIEWS.API_SETTINGS });
}
break;
case 'environments':
case 'logging':
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
break;
case 'settings-community-nodes':
if (this.$router.currentRoute.name !== VIEWS.COMMUNITY_NODES) {
this.$router.push({ name: VIEWS.COMMUNITY_NODES });
}
break;
default:
break;
}
},
},
});
</script>
<style lang="scss" module>
:global(.el-menu) {
--menu-item-height: 35px;
--submenu-item-height: 27px;
}
.container {
min-width: 200px;
height: 100%;
min-width: $sidebar-expanded-width;
height: 100vh;
background-color: var(--color-background-xlight);
border-right: var(--border-base);
position: relative;
padding: var(--spacing-xs);
overflow: auto;
ul {
height: 100%;
}
:global(.el-menu-item) > span{
position: relative;
left: 8px;
}
}
.settingsHeading {
position: relative;
left: 8px;
}
.tab {
margin-bottom: var(--spacing-2xs);
svg:global(.svg-inline--fa) { position: relative; }
}
.returnButton {
composes: tab;
margin-bottom: var(--spacing-l);
padding: 0 var(--spacing-xs);
height: 38px;
display: flex;
align-items: center;
color: var(--color-text-base);
font-size: var(--font-size-s);
padding: var(--spacing-s) var(--spacing-l);
cursor: pointer;
i {
color: var(--color-text-light);
}
&:hover > * {
&:hover {
color: var(--color-primary);
}
}
.usersMenu svg { left: -2px; }
.apiMenu svg { left: 2px; }
.icon {
width: 16px;
display: inline-flex;
margin-right: 10px;
}
.versionContainer {
position: absolute;
left: 23px;
bottom: 20px;
}
@media screen and (max-height: 420px) {
.updatesSubmenu, .versionContainer { display: none; }
}

View file

@ -53,6 +53,7 @@ import {
faFolderOpen,
faGlobeAmericas,
faGift,
faGlobe,
faGraduationCap,
faGripVertical,
faHdd,
@ -170,6 +171,7 @@ addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFolderOpen);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHdd);

View file

@ -33,7 +33,6 @@ import {
IWorkflowDb,
XYPosition,
IRestApiContext,
IWorkflowsState,
} from './Interface';
import nodeTypes from './modules/nodeTypes';

View file

@ -14,24 +14,9 @@
</div>
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
<n8n-menu default-active="owner" type="secondary" @select="onSelectOwner" ref="selectOwnerMenu">
<n8n-menu-item index="owner">
<template #title>
<n8n-icon icon="user"/>
<span class="ml-xs">
{{ $locale.baseText('credentials.menu.myCredentials') }}
</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="all">
<template #title>
<n8n-icon icon="globe-americas"/>
<span class="ml-xs">
{{ $locale.baseText('credentials.menu.allCredentials') }}
</span>
</template>
</n8n-menu-item>
</n8n-menu>
<div :class="$style.sidebarContainer">
<n8n-menu :items="menuItems" mode="tabs" @select="onSelectOwner" ref="selectOwnerMenu"></n8n-menu>
</div>
</enterprise-edition>
</template>
@ -256,6 +241,7 @@ import {EnterpriseEditionFeature} from "@/constants";
import TemplateCard from "@/components/TemplateCard.vue";
import Vue from "vue";
import { debounceHelper } from '@/components/mixins/debounce';
import { IMenuItem } from 'n8n-design-system';
export default mixins(
showMessage,
@ -290,6 +276,22 @@ export default mixins(
};
},
computed: {
menuItems(): IMenuItem[] {
return [
{
id: 'owner',
icon: 'user',
label: this.$locale.baseText('credentials.menu.myCredentials'),
position: 'top',
},
{
id: 'all',
icon: 'globe-americas',
label: this.$locale.baseText('credentials.menu.allCredentials'),
position: 'top',
},
];
},
currentUser(): IUser {
return this.$store.getters['users/currentUser'];
},
@ -561,5 +563,9 @@ export default mixins(
.type-input {
--max-width: 265px;
}
.sidebarContainer ul {
padding: 0 !important;
}
</style>