feat(editor): Sidebar redesign (#22395)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Daria <daria.staferova@n8n.io>
This commit is contained in:
Robert Squires
2025-12-03 08:03:16 +00:00
committed by GitHub
co-authored by Iván Ovejero Daria
parent f69721ffec
commit 0ba2043047
26 changed files with 944 additions and 618 deletions
@@ -30,6 +30,10 @@ import { FileLocation, BinaryDataService } from 'n8n-core';
import { NodeApiError, PROJECT_ROOT, assert } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history/workflow-history.service';
import { WorkflowSharingService } from './workflow-sharing.service';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -41,16 +45,11 @@ import { validateEntity } from '@/generic-helpers';
import type { ListQuery } from '@/requests';
import { hasSharing } from '@/requests';
import { OwnershipService } from '@/services/ownership.service';
// eslint-disable-next-line import-x/no-cycle
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { TagService } from '@/services/tag.service';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history/workflow-history.service';
import { WorkflowSharingService } from './workflow-sharing.service';
@Service()
export class WorkflowService {
constructor(
@@ -16,18 +16,18 @@ import { InstanceSettings } from 'n8n-core';
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { STARTING_NODES } from '@/constants';
import { ExecutionService } from '@/executions/execution.service';
import { ProjectService } from '@/services/project.service.ee';
import { Telemetry } from '@/telemetry';
import { createTag } from '../shared/db/tags';
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { createWorkflowHistoryItem } from '../shared/db/workflow-history';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { STARTING_NODES } from '@/constants';
import { ExecutionService } from '@/executions/execution.service';
import { ProjectService } from '@/services/project.service.ee';
import { Telemetry } from '@/telemetry';
mockInstance(Telemetry);
let ownerPersonalProject: Project;
@@ -29,17 +29,11 @@ import {
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { createFolder } from '@test-integration/db/folders';
import { DateTime } from 'luxon';
import { PROJECT_ROOT, type INode, type IPinData, type IWorkflowBase } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { ProjectService } from '@/services/project.service.ee';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import { createFolder } from '@test-integration/db/folders';
import { saveCredential } from '../shared/db/credentials';
import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles';
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
@@ -49,6 +43,12 @@ import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { ProjectService } from '@/services/project.service.ee';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
let owner: User;
let member: User;
let anotherMember: User;
@@ -81,7 +81,7 @@ const iconColor = computed(() => {
:color="iconColor"
>{{ item.icon.value }}</N8nText
>
<N8nIcon v-else-if="icon" :icon="icon" />
<N8nIcon v-else-if="icon" :color="iconColor" :icon="icon" />
</div>
<N8nText v-if="!compact" :class="$style.menuItemText">{{ item.label }}</N8nText>
<N8nIcon v-if="item.children && !compact" icon="chevron-right" color="text-light" />
@@ -98,17 +98,12 @@ const iconColor = computed(() => {
margin-bottom: var(--spacing--5xs);
}
.router-link-active,
.active {
background-color: var(--color--foreground);
}
.menuItem {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--3xs);
gap: var(--spacing--3xs);
padding: var(--spacing--4xs);
gap: var(--spacing--4xs);
cursor: pointer;
color: var(--color--text);
border-radius: var(--spacing--4xs);
@@ -120,11 +115,16 @@ const iconColor = computed(() => {
&:hover .menuItemIcon {
color: var(--color--text--shade-1);
}
}
.menuItem:hover:not(.active) {
background-color: var(--color--background--light-1);
color: var(--color--text--shade-1);
&:global(.router-link-active),
&.active {
background-color: var(--color--background--light-1);
}
&:hover:not(.active):not(:global(.router-link-active)) {
background-color: var(--color--background--light-1);
color: var(--color--text--shade-1);
}
}
.menuItem:focus-visible {
@@ -147,15 +147,18 @@ const iconColor = computed(() => {
.menuItemIcon {
position: relative;
width: var(--spacing--sm);
height: var(--spacing--sm);
min-width: var(--spacing--sm);
width: var(--spacing--lg);
height: var(--spacing--lg);
min-width: var(--spacing--lg);
display: flex;
align-items: center;
justify-content: center;
&.notification::after {
content: '';
position: absolute;
top: calc(var(--spacing--5xs) * -1);
right: calc(var(--spacing--5xs) * -1);
top: 0;
right: 0;
width: var(--spacing--4xs);
height: var(--spacing--4xs);
background-color: var(--color--danger);
@@ -1258,6 +1258,8 @@
"logs.details.body.multipleInputs": "Multiple inputs. View them by {button}",
"logs.details.body.multipleInputs.openingTheNode": "opening the node",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.state.collapse": "Collapse sidebar",
"mainSidebar.state.expand": "Expand sidebar",
"mainSidebar.confirmMessage.workflowArchive.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowArchive.confirmButtonText": "Yes, archive",
"mainSidebar.confirmMessage.workflowArchive.headline": "Archive Workflow?",
@@ -1283,6 +1285,7 @@
"mainSidebar.prompt.invalidUrl": "Invalid URL",
"mainSidebar.prompt.workflowUrl": "Workflow URL",
"mainSidebar.save": "@:_reusableBaseText.save",
"mainSidebar.settings": "Settings",
"mainSidebar.showError.stopExecution.title": "Problem stopping execution",
"mainSidebar.showMessage.handleFileImport.message": "The file does not contain valid JSON data",
"mainSidebar.showMessage.handleFileImport.title": "Could not import file",
@@ -3866,7 +3869,7 @@
"templateSetup.credential.description": "The credential you select will be used in the {0} node of the workflow template. | The credential you select will be used in the {0} nodes of the workflow template.",
"templateSetup.continue.button.fillRemaining": "Fill remaining credentials to continue",
"setupCredentialsModal.title": "Set up template",
"becomeCreator.text": "Share your workflows with 40k+ users, unlock perks, and shine as a featured template creator!",
"becomeCreator.text": "Share your workflows with other users, unlock perks, and become a featured template creator!",
"becomeCreator.buttonText": "Become a creator",
"becomeCreator.closeButtonTitle": "Close",
"feedback.title": "Was this helpful?",
@@ -4065,7 +4068,7 @@
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
"insights.upgradeModal.perks.2": "Gain deeper visibility into workflow trends over time",
"insights.upgradeModal.title": "Upgrade to Enterprise",
"whatsNew.versionsBehind": "{count} version behind | {count} versions behind",
"whatsNew.versionsBehind": "Update ({count} version behind) | Update ({count} versions behind)",
"whatsNew.update": "Update",
"whatsNew.updateNudgeTooltip": "Only owners can perform updates",
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
@@ -3,7 +3,7 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { useBecomeTemplateCreatorStore } from './becomeTemplateCreatorStore';
import { useI18n } from '@n8n/i18n';
import { N8nButton, N8nIcon } from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
const i18n = useI18n();
const store = useBecomeTemplateCreatorStore();
const telemetry = useTelemetry();
@@ -20,23 +20,22 @@ const onClick = () => {
data-test-id="become-template-creator-cta"
>
<div :class="$style.textAndCloseButton">
<p :class="$style.text">
<N8nText size="small">
{{ i18n.baseText('becomeCreator.text') }}
</p>
</N8nText>
<button
:class="$style.closeButton"
data-test-id="close-become-template-creator-cta"
@click="store.dismissCta()"
>
<N8nIcon icon="x" size="xsmall" :title="i18n.baseText('generic.close')" />
<N8nIcon icon="x" :title="i18n.baseText('generic.close')" />
</button>
</div>
<N8nButton
:class="$style.becomeCreatorButton"
:label="i18n.baseText('becomeCreator.buttonText')"
size="xmini"
type="secondary"
element="a"
href="https://creators.n8n.io/hub"
@@ -52,7 +51,8 @@ const onClick = () => {
flex-direction: column;
background-color: var(--color--background--light-2);
border: var(--border);
border-right: 0;
margin: var(--spacing--2xs);
border-radius: var(--radius);
}
.textAndCloseButton {
@@ -9,10 +9,17 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useVersionsStore } from '@/app/stores/versions.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
import type { Version } from '@n8n/rest-api-client/api/versions';
import { ABOUT_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/app/constants';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRouter: () => ({
resolve: vi.fn(() => ({ meta: {} })),
}),
useRoute: () => reactive({}),
RouterLink: vi.fn(),
}));
@@ -23,6 +30,10 @@ let uiStore: MockedStore<typeof useUIStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let versionsStore: MockedStore<typeof useVersionsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let templatesStore: MockedStore<typeof useTemplatesStore>;
let personalizedTemplatesV2Store: MockedStore<typeof usePersonalizedTemplatesV2Store>;
let personalizedTemplatesV3Store: MockedStore<typeof usePersonalizedTemplatesV3Store>;
let templatesDataQualityStore: MockedStore<typeof useTemplatesDataQualityStore>;
const mockVersion: Version = {
name: '1.2.0',
@@ -46,6 +57,10 @@ describe('MainSidebar', () => {
sourceControlStore = mockedStore(useSourceControlStore);
versionsStore = mockedStore(useVersionsStore);
usersStore = mockedStore(useUsersStore);
templatesStore = mockedStore(useTemplatesStore);
personalizedTemplatesV2Store = mockedStore(usePersonalizedTemplatesV2Store);
personalizedTemplatesV3Store = mockedStore(usePersonalizedTemplatesV3Store);
templatesDataQualityStore = mockedStore(useTemplatesDataQualityStore);
settingsStore.settings = defaultSettings;
@@ -54,6 +69,14 @@ describe('MainSidebar', () => {
versionsStore.nextVersions = [];
usersStore.canUserUpdateVersion = true;
uiStore.sidebarMenuCollapsed = false;
settingsStore.isTemplatesEnabled = true;
templatesStore.hasCustomTemplatesHost = false;
templatesStore.websiteTemplateRepositoryURL = 'https://n8n.io/workflows';
// Default experiment store values
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false);
templatesDataQualityStore.isFeatureEnabled = vi.fn(() => false);
});
it('renders the sidebar without error', () => {
@@ -87,18 +110,16 @@ describe('MainSidebar', () => {
expect(queryByTestId('version-update-cta-button')).not.toBeInTheDocument();
});
it('should render version update CTA disabled when canUserUpdateVersion is false', async () => {
it('should not render version update CTA when canUserUpdateVersion is false', async () => {
versionsStore.hasVersionUpdates = true;
versionsStore.nextVersions = [mockVersion];
usersStore.canUserUpdateVersion = false;
const { findByTestId, getByText } = renderComponent();
const { queryByTestId, getByText } = renderComponent();
getByText('What’s New').click();
getByText('Help').click();
const updateButton = await findByTestId('version-update-cta-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeDisabled();
expect(queryByTestId('version-update-cta-button')).not.toBeInTheDocument();
});
it('should render version update CTA enabled when canUserUpdateVersion is true and hasVersionUpdates is true', async () => {
@@ -108,11 +129,107 @@ describe('MainSidebar', () => {
const { getByText, findByTestId } = renderComponent();
getByText('What’s New').click();
getByText('Help').click();
const updateButton = await findByTestId('version-update-cta-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeEnabled();
});
});
describe('mainMenuItems', () => {
it('should show templates menu when templates are enabled and no experiment is active', () => {
settingsStore.isTemplatesEnabled = true;
templatesStore.hasCustomTemplatesHost = false;
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false);
templatesDataQualityStore.isFeatureEnabled = vi.fn(() => false);
const { getAllByTestId } = renderComponent();
// Should have at least one templates item visible
const templatesItems = getAllByTestId('main-sidebar-templates');
expect(templatesItems.length).toBeGreaterThan(0);
});
it('should show templates menu when experiment is enabled', () => {
settingsStore.isTemplatesEnabled = true;
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => true);
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
templatesDataQualityStore.isFeatureEnabled = vi.fn(() => false);
const { getAllByTestId } = renderComponent();
// Should have templates item visible when experiment is enabled
const templatesItems = getAllByTestId('main-sidebar-templates');
expect(templatesItems.length).toBeGreaterThan(0);
});
it('should not show templates menu when templates are disabled', () => {
settingsStore.isTemplatesEnabled = false;
const { queryAllByTestId } = renderComponent();
// Should have no templates items when templates are disabled
const templatesItems = queryAllByTestId('main-sidebar-templates');
expect(templatesItems).toHaveLength(0);
});
it('should show help menu item', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('main-sidebar-help')).toBeInTheDocument();
});
it('should show settings menu item', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('main-sidebar-settings')).toBeInTheDocument();
});
});
describe('handleSelect', () => {
beforeEach(() => {
uiStore.openModal = vi.fn();
uiStore.openModalWithData = vi.fn();
personalizedTemplatesV3Store.markTemplateRecommendationInteraction = vi.fn();
});
it('should open about modal when about is selected', async () => {
const { getByText, findByText } = renderComponent();
getByText('Help').click();
const aboutItem = await findByText('About n8n');
aboutItem.click();
expect(uiStore.openModal).toHaveBeenCalledWith(ABOUT_MODAL_KEY);
});
it('should open whats new modal when whats new article is selected', async () => {
versionsStore.hasVersionUpdates = true;
versionsStore.whatsNewArticles = [
{
id: 123,
title: 'Test Article',
content: 'Test content',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: null,
publishedAt: '2025-01-01T00:00:00Z',
},
];
const { getByText, findByText } = renderComponent();
getByText('Help').click();
const articleItem = await findByText('Test Article');
articleItem.click();
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: WHATS_NEW_MODAL_KEY,
data: {
articleId: 123,
},
});
});
});
});
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref, useTemplateRef } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@n8n/i18n';
@@ -16,8 +16,9 @@ import {
N8nText,
N8nIcon,
N8nButton,
N8nResizeWrapper,
} from '@n8n/design-system';
import type { IMenuItem } from '@n8n/design-system';
import type { IMenuItem, IMenuElement } from '@n8n/design-system';
import {
ABOUT_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
@@ -39,8 +40,6 @@ import { useUsersStore } from '@/features/settings/users/users.store';
import { useVersionsStore } from '@/app/stores/versions.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useDebounce } from '@/app/composables/useDebounce';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useBugReporting } from '@/app/composables/useBugReporting';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
@@ -53,12 +52,16 @@ import { I18nT } from 'vue-i18n';
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
import TemplateTooltip from '@/experiments/personalizedTemplatesV3/components/TemplateTooltip.vue';
import { useKeybindings } from '@/app/composables/useKeybindings';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import ProjectNavigation from '@/features/collaboration/projects/components/ProjectNavigation.vue';
import KeyboardShortcutTooltip from './KeyboardShortcutTooltip.vue';
import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar';
import MainSidebarSourceControl from './MainSidebarSourceControl.vue';
import MainSidebarUserArea from '@/app/components/MainSidebarUserArea.vue';
import TemplateTooltip from '@/experiments/personalizedTemplatesV3/components/TemplateTooltip.vue';
import { useSidebarLayout } from '../composables/useSidebarLayout';
import { useSettingsItems } from '../composables/useSettingsItems';
import { useRouter } from 'vue-router';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
const cloudPlanStore = useCloudPlanStore();
@@ -74,24 +77,26 @@ const personalizedTemplatesV2Store = usePersonalizedTemplatesV2Store();
const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
const templatesDataQualityStore = useTemplatesDataQualityStore();
const { callDebounced } = useDebounce();
const externalHooks = useExternalHooks();
const i18n = useI18n();
const router = useRouter();
const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
const { getReportingURL } = useBugReporting();
const calloutHelpers = useCalloutHelpers();
const { isCollapsed, sidebarWidth, onResizeStart, onResize, onResizeEnd, toggleCollapse } =
useSidebarLayout();
useKeybindings({
ctrl_alt_o: () => handleSelect('about'),
['bracketleft']: () => toggleCollapse(),
});
// Template refs
const user = useTemplateRef('user');
const { isEnabled: isCommandBarEnabled } = useCommandBar();
const { settingsItems } = useSettingsItems();
// Component data
const basePath = ref('');
const fullyExpanded = ref(false);
const showWhatsNewNotification = computed(
() =>
@@ -190,6 +195,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
id: 'help',
icon: 'circle-help',
label: i18n.baseText('mainSidebar.help'),
notification: showWhatsNewNotification.value,
position: 'bottom',
children: [
{
@@ -246,81 +252,71 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
],
},
{
id: 'whats-new',
icon: 'bell',
notification: showWhatsNewNotification.value,
label: i18n.baseText('mainSidebar.whatsNew'),
position: 'bottom',
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
children: [
...versionsStore.whatsNewArticles.map(
(article) =>
({
id: `whats-new-article-${article.id}`,
label: article.title,
size: 'small',
customIconSize: 'small',
icon: {
type: 'emoji',
value: '•',
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
},
}) satisfies IMenuItem,
),
{
id: 'full-changelog',
icon: 'external-link',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',
customIconSize: 'small',
},
{
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
available: versionsStore.hasVersionUpdates,
props: {
disabled: !usersStore.canUserUpdateVersion,
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
},
},
],
id: 'settings',
label: i18n.baseText('mainSidebar.settings'),
icon: 'settings',
available: true,
children: settingsItems.value,
},
]);
const visibleMenuItems = computed(() =>
const visibleMenuItems = computed<IMenuItem[]>(() =>
mainMenuItems.value.filter((item) => item.available !== false),
);
const whatsNewItems = computed<{ available: boolean; children: IMenuElement[] }>(() => ({
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
children: [
...versionsStore.whatsNewArticles.map(
(article) =>
({
id: `whats-new-article-${article.id}`,
label: article.title,
size: 'small',
customIconSize: 'small',
icon: {
type: 'emoji',
value: '•',
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
},
}) satisfies IMenuItem,
),
{
id: 'full-changelog',
icon: 'external-link',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',
customIconSize: 'small',
},
{
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
available: versionsStore.hasVersionUpdates && usersStore.canUserUpdateVersion,
props: {
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
},
},
],
}));
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
const showUserArea = computed(() => hasPermission(['authenticated']));
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
onMounted(async () => {
window.addEventListener('resize', onResize);
onMounted(() => {
basePath.value = rootStore.baseUrl;
if (user.value?.$el) {
void externalHooks.run('mainSidebar.mounted', {
userRef: user.value.$el,
});
}
becomeTemplateCreatorStore.startMonitoringCta();
await nextTick(onResizeEnd);
});
onBeforeUnmount(() => {
becomeTemplateCreatorStore.stopMonitoringCta();
window.removeEventListener('resize', onResize);
});
const trackHelpItemClick = (itemType: string) => {
@@ -330,17 +326,20 @@ const trackHelpItemClick = (itemType: string) => {
});
};
const toggleCollapse = () => {
uiStore.toggleSidebarMenuCollapse();
// When expanding, delay showing some element to ensure smooth animation
if (!isCollapsed.value) {
setTimeout(() => {
fullyExpanded.value = !isCollapsed.value;
}, 300);
} else {
fullyExpanded.value = !isCollapsed.value;
}
};
function openCommandBar(event: MouseEvent) {
event.stopPropagation();
void nextTick(() => {
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'k',
code: 'KeyK',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(keyboardEvent);
});
}
const handleSelect = (key: string) => {
switch (key) {
@@ -402,22 +401,6 @@ const handleSelect = (key: string) => {
}
};
function onResize() {
void callDebounced(onResizeEnd, { debounceTime: 250 });
}
async function onResizeEnd() {
if (window.innerWidth < 900) {
uiStore.sidebarMenuCollapsed = true;
} else {
uiStore.sidebarMenuCollapsed = uiStore.sidebarMenuCollapsedPreference;
}
void nextTick(() => {
fullyExpanded.value = !isCollapsed.value;
});
}
const {
menu,
handleSelect: handleMenuSelect,
@@ -431,27 +414,33 @@ const {
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
const onLogout = () => {
void router.push({ name: VIEWS.SIGNOUT });
};
</script>
<template>
<div
<N8nResizeWrapper
id="side-menu"
:class="{
['side-menu']: true,
[$style.sideMenu]: true,
[$style.sideMenuCollapsed]: isCollapsed,
}"
:width="sidebarWidth"
:style="{ width: `${sidebarWidth}px` }"
:supported-directions="['right']"
:min-width="200"
:max-width="500"
:grid-size="8"
@resizestart="onResizeStart"
@resize="onResize"
@resizeend="onResizeEnd"
>
<div
id="collapse-change-button"
:class="['clickable', $style.sideMenuCollapseButton]"
@click="toggleCollapse"
>
<N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
</div>
<div :class="$style.logo">
<div :class="$style.header">
<N8nLogo
v-if="!isCollapsed"
:class="$style.logo"
size="small"
:collapsed="isCollapsed"
:release-channel="settingsStore.settings.releaseChannel"
@@ -475,18 +464,43 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<N8nIcon
data-test-id="read-only-env-icon"
icon="lock"
size="xsmall"
:class="$style.readOnlyEnvironmentIcon"
/>
</N8nTooltip>
</N8nLogo>
<KeyboardShortcutTooltip
:placement="isCollapsed ? 'right' : 'bottom'"
:label="
isCollapsed
? i18n.baseText('mainSidebar.state.expand')
: i18n.baseText('mainSidebar.state.collapse')
"
show-after="500"
:shortcut="{ keys: ['['] }"
>
<N8nIconButton
id="toggle-sidebar-button"
size="small"
type="highlight"
icon="panel-left"
icon-size="large"
aria-label="Toggle sidebar"
@click="toggleCollapse"
/>
</KeyboardShortcutTooltip>
<N8nNavigationDropdown
ref="createBtn"
data-test-id="universal-add"
:menu="menu"
@select="handleMenuSelect"
>
<N8nIconButton icon="plus" type="secondary" outline />
<N8nIconButton
size="small"
type="highlight"
icon="plus"
icon-size="large"
aria-label="Add new item"
/>
<template #[createWorkflowsAppendSlotName]>
<N8nTooltip
v-if="sourceControlStore.preferences.branchReadOnly"
@@ -536,6 +550,22 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</N8nTooltip>
</template>
</N8nNavigationDropdown>
<KeyboardShortcutTooltip
v-if="isCommandBarEnabled"
:placement="isCollapsed ? 'right' : 'bottom'"
show-after="500"
:label="i18n.baseText('nodeView.openCommandBar')"
:shortcut="{ keys: ['k'], metaKey: true }"
>
<N8nIconButton
size="small"
type="highlight"
icon="search"
icon-size="large"
aria-label="Open command palette"
@click="openCommandBar"
/>
</KeyboardShortcutTooltip>
</div>
<N8nScrollArea as-child>
<div :class="$style.scrollArea">
@@ -543,23 +573,20 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
:collapsed="isCollapsed"
:plan-name="cloudPlanStore.currentPlanData?.displayName"
/>
<div :class="$style.bottomMenu">
<BecomeTemplateCreatorCta v-if="fullyExpanded && !userIsTrialing" />
<div :class="$style.bottomMenuItems">
<template v-for="item in visibleMenuItems" :key="item.id">
<!-- Help popover -->
<N8nPopoverReka
v-if="item.children"
:key="item.id"
v-if="item.children && item.id === 'help'"
key="help"
side="right"
align="end"
:side-offset="16"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<N8nText :class="$style.popoverTitle" bold color="foreground-xdark">{{
item.label
}}</N8nText>
<BecomeTemplateCreatorCta v-if="!isCollapsed && !userIsTrialing" />
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
@@ -568,18 +595,69 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<template v-if="whatsNewItems.available">
<N8nText bold size="small" :class="$style.popoverTitle" color="text-light"
>What's new</N8nText
>
<template v-for="child in whatsNewItems.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
</template>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopoverReka>
<!-- Settings popover -->
<N8nPopoverReka
v-else-if="item.children && item.id === 'settings'"
key="settings"
side="right"
align="end"
:side-offset="12"
>
<template #content>
<div :class="$style.popover">
<template v-for="child in item.children" :key="child.id">
<component
:is="child.component"
v-if="isCustomMenuItem(child)"
v-bind="child.props"
/>
<N8nMenuItem v-else :item="child" @click="() => handleSelect(child.id)" />
</template>
<span :class="$style.divider" />
<N8nMenuItem
:data-test-id="'main-sidebar-log-out'"
:item="{ id: 'sign-out', label: 'Sign out', icon: 'door-open' }"
@click="onLogout"
/>
</div>
</template>
<template #trigger>
<N8nMenuItem
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
/>
</template>
</N8nPopoverReka>
<!-- Items without children -->
<N8nMenuItem
v-else
:data-test-id="`main-sidebar-${item.id}`"
:item="item"
:compact="isCollapsed"
@click="() => handleSelect(item.id)"
@@ -589,17 +667,9 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</div>
</div>
</N8nScrollArea>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
<MainSidebarUserArea
v-if="showUserArea"
ref="user"
:fully-expanded="fullyExpanded"
:is-collapsed="isCollapsed"
/>
<TemplateTooltip />
</div>
</N8nResizeWrapper>
</template>
<style lang="scss" module>
@@ -608,20 +678,22 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
height: 100%;
display: flex;
flex-direction: column;
border-right: var(--border-width) var(--border-style) var(--color--foreground);
width: $sidebar-expanded-width;
background-color: var(--menu--color--background, var(--color--background--light-1));
border-right: var(--border);
background-color: var(--menu--color--background, var(--color--background--light-2));
.logo {
.header {
display: flex;
align-items: center;
padding: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--3xs);
margin-bottom: var(--spacing--2xs);
justify-content: space-between;
gap: var(--spacing--4xs);
img {
position: relative;
left: 1px;
height: 20px;
margin-right: auto;
}
}
@@ -629,57 +701,58 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
width: $sidebar-width;
min-width: auto;
.logo {
.header {
flex-direction: column;
gap: 12px;
border-bottom: var(--border);
}
}
}
.logo {
margin-right: auto;
}
.scrollArea {
height: 100%;
display: flex;
flex-direction: column;
}
.sideMenuCollapseButton {
position: absolute;
right: -10px;
top: 50%;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
color: var(--color--text);
background-color: var(--color--foreground--tint-2);
width: 20px;
height: 20px;
border: var(--border-width) var(--border-style) var(--color--foreground);
border-radius: 50%;
&:hover {
color: var(--color--primary--shade-1);
}
}
.bottomMenu {
display: flex;
flex-direction: column;
margin-top: auto;
}
.sideMenuCollapsed .bottomMenu {
border-top: var(--border);
}
.bottomMenuItems {
padding: var(--spacing--xs);
padding: var(--spacing--3xs);
}
.popover {
padding: var(--spacing--xs);
min-width: 200px;
padding: var(--spacing--4xs);
min-width: 260px;
border-radius: var(--radius);
background-color: var(--menu--color--background, var(--color--background--light-2));
}
.divider {
display: block;
width: 100%;
padding-top: var(--spacing--3xs);
border-bottom: var(--border);
margin-bottom: var(--spacing--3xs);
background-color: var(--color--border);
}
.popoverTitle {
display: block;
margin-bottom: var(--spacing--3xs);
padding-left: var(--spacing--3xs);
margin-top: var(--spacing--xs);
}
@media screen and (max-height: 470px) {
@@ -7,7 +7,7 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useRoute, useRouter } from 'vue-router';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
defineProps<{
isCollapsed: boolean;
}>();
@@ -44,6 +44,25 @@ const sourceControlAvailable = computed(
(hasPullPermission.value || hasPushPermission.value),
);
function getAccessibleTextColor(backgroundColor: string): string {
const hex = backgroundColor.replace('#', '');
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const getLuminance = (channel: number) => {
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
};
const luminance = 0.2126 * getLuminance(r) + 0.7152 * getLuminance(g) + 0.0722 * getLuminance(b);
return luminance > 0.5 ? '#000000' : '#ffffff';
}
const accessibleTextColor = computed(() => {
return getAccessibleTextColor(sourceControlStore.preferences.branchColor);
});
async function pushWorkfolder() {
// Navigate to route with sourceControl param - modal will handle data loading and loading states
void router.push({
@@ -73,7 +92,6 @@ function pullWorkfolder() {
[$style.collapsed]: isCollapsed,
[$style.isConnected]: sourceControlStore.isEnterpriseSourceControlEnabled,
}"
:style="{ borderLeftColor: sourceControlStore.preferences.branchColor }"
data-test-id="main-sidebar-source-control"
>
<div
@@ -81,11 +99,26 @@ function pullWorkfolder() {
:class="$style.connected"
data-test-id="main-sidebar-source-control-connected"
>
<span :class="$style.branchName">
<N8nIcon icon="git-branch" />
{{ currentBranch }}
</span>
<div :class="{ 'pt-xs': !isCollapsed }">
<N8nTooltip :disabled="!isCollapsed" :show-after="tooltipOpenDelay" placement="right">
<template #content>
<div>
{{ currentBranch }}
</div>
</template>
<span
:class="$style.icon"
:style="{
color: accessibleTextColor,
background: sourceControlStore.preferences.branchColor,
}"
>
<N8nIcon icon="git-branch" size="small" />
<N8nText v-if="!isCollapsed" bold size="small" :class="$style.branchName">{{
currentBranch
}}</N8nText>
</span>
</N8nTooltip>
<div>
<N8nTooltip
:disabled="!isCollapsed && hasPullPermission"
:show-after="tooltipOpenDelay"
@@ -101,15 +134,12 @@ function pullWorkfolder() {
</div>
</template>
<N8nButton
:class="{
'mr-2xs': !isCollapsed,
'mb-2xs': isCollapsed,
}"
:disabled="!hasPullPermission"
data-test-id="main-sidebar-source-control-pull"
icon="arrow-down"
type="tertiary"
size="mini"
:size="isCollapsed ? 'small' : 'mini'"
text
:square="isCollapsed"
:label="isCollapsed ? '' : i18n.baseText('settings.sourceControl.button.pull')"
@click="pullWorkfolder"
@@ -138,7 +168,8 @@ function pullWorkfolder() {
data-test-id="main-sidebar-source-control-push"
icon="arrow-up"
type="tertiary"
size="mini"
text
:size="isCollapsed ? 'small' : 'mini'"
@click="pushWorkfolder"
/>
</N8nTooltip>
@@ -149,42 +180,53 @@ function pullWorkfolder() {
<style lang="scss" module>
.sync {
padding: var(--spacing--sm) var(--spacing--sm) var(--spacing--sm) var(--spacing--lg);
background: var(--color--background--light-2);
border-top: var(--border-width) var(--border-style) var(--color--foreground);
font-size: var(--font-size--2xs);
&.isConnected {
padding-left: var(--spacing--md);
border-left: var(--spacing--3xs) var(--border-style) var(--color--foreground);
&.collapsed {
padding-left: var(--spacing--xs);
}
}
&:empty {
display: none;
}
padding: 0 var(--spacing--5xs) 0 0;
button {
font-size: var(--font-size--3xs);
font-size: var(--font-size--2xs);
}
&.collapsed {
padding: var(--spacing--2xs) 0 0;
}
}
.icon {
padding: var(--spacing--4xs) var(--spacing--xs);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--3xs);
}
.connected {
display: flex;
align-items: center;
justify-content: space-between;
border-top: var(--border);
padding-right: var(--spacing--4xs);
}
.branchName {
white-space: normal;
line-break: anywhere;
margin-top: -1px;
}
.collapsed {
text-align: center;
padding-left: var(--spacing--sm);
padding-right: var(--spacing--sm);
flex-direction: column-reverse;
padding-top: var(--spacing--4xs);
.connected {
> span {
display: none;
flex-direction: column-reverse;
gap: var(--spacing--3xs);
padding-right: 0;
.icon {
width: 100%;
padding: var(--spacing--3xs) 0;
justify-content: center;
}
}
}
@@ -11,11 +11,11 @@ import {
IMPORT_WORKFLOW_URL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
VERSIONS_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
NPS_SURVEY_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY,
SETUP_CREDENTIALS_MODAL_KEY,
VERSIONS_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
PRE_BUILT_AGENTS_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
@@ -98,7 +98,6 @@ import SourceControlPullModal from '@/features/integrations/sourceControl.ee/com
import SourceControlPushModal from '@/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue';
import AnnotationTagsManager from '@/features/shared/tags/components/TagsManager/AnnotationTagsManager.ee.vue';
import WorkflowTagsManager from '@/features/shared/tags/components/TagsManager/WorkflowTagsManager.vue';
import UpdatesPanel from '@/app/components/UpdatesPanel.vue';
import WhatsNewModal from '@/app/components/WhatsNewModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/app/components/WorkflowActivationConflictingWebhookModal.vue';
import WorkflowExtractionNameModal from '@/app/components/WorkflowExtractionNameModal.vue';
@@ -116,6 +115,7 @@ import NodeRecommendationModalTDQ from '@/experiments/templatesDataQuality/compo
import VariableModal from '@/features/settings/environments.ee/components/VariableModal.vue';
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue';
import UpdatesPanel from './UpdatesPanel.vue';
</script>
<template>
@@ -1,148 +1,21 @@
<script lang="ts" setup>
import { useUserHelpers } from '@/app/composables/useUserHelpers';
import { ABOUT_MODAL_KEY, VIEWS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { ABOUT_MODAL_KEY } from '@/app/constants';
import { N8nIcon, N8nLink, N8nMenuItem, N8nText } from '@n8n/design-system';
import { useSettingsItems } from '../composables/useSettingsItems';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useUIStore } from '../stores/ui.store';
import { N8nIcon, N8nLink, N8nMenuItem, N8nText, type IMenuItem } from '@n8n/design-system';
const emit = defineEmits<{
return: [];
}>();
const router = useRouter();
const i18n = useI18n();
const { canUserAccessRouteByName } = useUserHelpers(router);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const sidebarMenuItems = computed<IMenuItem[]>(() => {
const menuItems: IMenuItem[] = [
{
id: 'settings-usage-and-plan',
icon: 'chart-column-decreasing',
label: i18n.baseText('settings.usageAndPlan.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.USAGE),
route: { to: { name: VIEWS.USAGE } },
},
{
id: 'settings-personal',
icon: 'circle-user-round',
label: i18n.baseText('settings.personal'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS),
route: { to: { name: VIEWS.PERSONAL_SETTINGS } },
},
{
id: 'settings-users',
icon: 'user-round',
label: i18n.baseText('settings.users'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.USERS_SETTINGS),
route: { to: { name: VIEWS.USERS_SETTINGS } },
},
{
id: 'settings-project-roles',
icon: 'user-round',
label: i18n.baseText('settings.projectRoles'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.PROJECT_ROLES_SETTINGS),
route: { to: { name: VIEWS.PROJECT_ROLES_SETTINGS } },
},
{
id: 'settings-api',
icon: 'plug',
label: i18n.baseText('settings.n8napi'),
position: 'top',
available: settingsStore.isPublicApiEnabled && canUserAccessRouteByName(VIEWS.API_SETTINGS),
route: { to: { name: VIEWS.API_SETTINGS } },
},
{
id: 'settings-external-secrets',
icon: 'vault',
label: i18n.baseText('settings.externalSecrets.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS),
route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } },
},
{
id: 'settings-source-control',
icon: 'git-branch',
label: i18n.baseText('settings.sourceControl.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.SOURCE_CONTROL),
route: { to: { name: VIEWS.SOURCE_CONTROL } },
},
{
id: 'settings-sso',
icon: 'user-lock',
label: i18n.baseText('settings.sso'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.SSO_SETTINGS),
route: { to: { name: VIEWS.SSO_SETTINGS } },
},
{
id: 'settings-ldap',
icon: 'network',
label: i18n.baseText('settings.ldap'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
route: { to: { name: VIEWS.LDAP_SETTINGS } },
},
{
id: 'settings-workersview',
icon: 'waypoints',
label: i18n.baseText('mainSidebar.workersView'),
position: 'top',
available:
settingsStore.isQueueModeEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'workersView:manage' } }),
route: { to: { name: VIEWS.WORKER_VIEW } },
},
];
menuItems.push({
id: 'settings-log-streaming',
icon: 'log-in',
label: i18n.baseText('settings.log-streaming'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS),
route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } },
});
menuItems.push({
id: 'settings-community-nodes',
icon: 'box',
label: i18n.baseText('settings.communityNodes'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.COMMUNITY_NODES),
route: { to: { name: VIEWS.COMMUNITY_NODES } },
});
menuItems.push({
id: 'settings-migration-report',
icon: 'list-checks',
label: i18n.baseText('settings.migrationReport'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.MIGRATION_REPORT),
route: { to: { name: VIEWS.MIGRATION_REPORT } },
});
// Append module-registered settings sidebar items.
const moduleItems = uiStore.settingsSidebarItems;
return menuItems.concat(moduleItems.filter((item) => !menuItems.some((m) => m.id === item.id)));
});
const visibleItems = computed(() => sidebarMenuItems.value.filter((item) => item.available));
const { settingsItems } = useSettingsItems();
</script>
<template>
@@ -154,7 +27,7 @@ const visibleItems = computed(() => sidebarMenuItems.value.filter((item) => item
<N8nText bold>{{ i18n.baseText('settings') }}</N8nText>
</div>
<div :class="$style.items">
<N8nMenuItem v-for="item in visibleItems" :key="item.id" :item="item" />
<N8nMenuItem v-for="item in settingsItems" :key="item.id" :item="item" />
</div>
<div :class="$style.versionContainer">
<N8nLink size="small" @click="uiStore.openModal(ABOUT_MODAL_KEY)">
@@ -1,16 +1,14 @@
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { waitFor, within } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { useUIStore } from '@/app/stores/ui.store';
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/app/constants';
import { WHATS_NEW_MODAL_KEY } from '@/app/constants';
import { useVersionsStore } from '@/app/stores/versions.store';
import type { Version } from '@n8n/rest-api-client/api/versions';
import VersionUpdateCTA from './VersionUpdateCTA.vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
vi.mock('@/app/composables/usePageRedirectionHelper', () => {
const goToVersions = vi.fn();
@@ -40,7 +38,6 @@ let uiStore: MockedStore<typeof useUIStore>;
let versionsStore: MockedStore<typeof useVersionsStore>;
const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
const version: Version = {
name: '1.100.0',
@@ -83,27 +80,12 @@ describe('VersionUpdateCTA', () => {
const { getByTestId } = renderComponent();
await waitFor(() => expect(getByTestId('version-update-cta-button')).toBeInTheDocument());
const update = getByTestId('version-update-cta-button');
await userEvent.click(getByTestId('version-update-cta-button'));
await userEvent.click(within(update).getByRole('menuitem'));
expect(pageRedirectionHelper.goToVersions).toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User clicked on update button', {
source: 'main-sidebar',
});
});
it('should open the next versions drawer when clicking on the next versions link', async () => {
versionsStore.hasVersionUpdates = true;
const { getByTestId } = renderComponent();
await waitFor(() =>
expect(getByTestId('version-update-next-versions-link')).toBeInTheDocument(),
);
await userEvent.click(getByTestId('version-update-next-versions-link'));
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
});
});
@@ -2,11 +2,9 @@
import { useVersionsStore } from '@/app/stores/versions.store';
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useUIStore } from '@/app/stores/ui.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { VERSIONS_MODAL_KEY } from '@/app/constants';
import { N8nMenuItem } from '@n8n/design-system';
import { N8nButton, N8nLink, N8nTooltip } from '@n8n/design-system';
interface Props {
disabled?: boolean;
tooltipText?: string;
@@ -19,14 +17,10 @@ const props = withDefaults(defineProps<Props>(), {
const i18n = useI18n();
const versionsStore = useVersionsStore();
const uiStore = useUIStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const telemetry = useTelemetry();
const openUpdatesPanel = () => {
uiStore.openModal(VERSIONS_MODAL_KEY);
};
const onUpdateClick = async () => {
telemetry.track('User clicked on update button', {
source: 'main-sidebar',
@@ -37,50 +31,18 @@ const onUpdateClick = async () => {
</script>
<template>
<div :class="$style.container">
<N8nLink
size="small"
theme="text"
data-test-id="version-update-next-versions-link"
@click="openUpdatesPanel"
>
{{
i18n.baseText('whatsNew.versionsBehind', {
interpolate: {
count:
versionsStore.nextVersions.length > 99 ? '99+' : versionsStore.nextVersions.length,
},
})
}}
</N8nLink>
<N8nTooltip :disabled="!props.tooltipText" :content="props.tooltipText">
<N8nButton
:class="$style.button"
:label="i18n.baseText('whatsNew.update')"
data-test-id="version-update-cta-button"
size="mini"
:disabled="props.disabled"
@click="onUpdateClick"
/>
</N8nTooltip>
</div>
<N8nMenuItem
data-test-id="version-update-cta-button"
:item="{
id: 'version-update-cta',
icon: { value: 'status-warning', type: 'icon', color: 'primary' },
disabled: props.disabled,
label: i18n.baseText('whatsNew.versionsBehind', {
interpolate: {
count: versionsStore.nextVersions.length > 99 ? '99+' : versionsStore.nextVersions.length,
},
}),
}"
@click="onUpdateClick"
/>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
border-radius: var(--radius);
border: var(--border);
background: var(--color--background--light-1);
margin-top: var(--spacing--xs);
}
.button {
width: 100%;
}
</style>
@@ -0,0 +1,140 @@
import { useRouter } from 'vue-router';
import { useUserHelpers } from './useUserHelpers';
import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { VIEWS } from '../constants';
import { useUIStore } from '../stores/ui.store';
import { useSettingsStore } from '../stores/settings.store';
import { hasPermission } from '../utils/rbac/permissions';
export function useSettingsItems() {
const router = useRouter();
const i18n = useI18n();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const { canUserAccessRouteByName } = useUserHelpers(router);
const settingsItems = computed<IMenuItem[]>(() => {
const menuItems: IMenuItem[] = [
{
id: 'settings-usage-and-plan',
icon: 'chart-column-decreasing',
label: i18n.baseText('settings.usageAndPlan.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.USAGE),
route: { to: { name: VIEWS.USAGE } },
},
{
id: 'settings-personal',
icon: 'circle-user-round',
label: i18n.baseText('settings.personal'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS),
route: { to: { name: VIEWS.PERSONAL_SETTINGS } },
},
{
id: 'settings-users',
icon: 'user-round',
label: i18n.baseText('settings.users'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.USERS_SETTINGS),
route: { to: { name: VIEWS.USERS_SETTINGS } },
},
{
id: 'settings-project-roles',
icon: 'user-round',
label: i18n.baseText('settings.projectRoles'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.PROJECT_ROLES_SETTINGS),
route: { to: { name: VIEWS.PROJECT_ROLES_SETTINGS } },
},
{
id: 'settings-api',
icon: 'plug',
label: i18n.baseText('settings.n8napi'),
position: 'top',
available: settingsStore.isPublicApiEnabled && canUserAccessRouteByName(VIEWS.API_SETTINGS),
route: { to: { name: VIEWS.API_SETTINGS } },
},
{
id: 'settings-external-secrets',
icon: 'vault',
label: i18n.baseText('settings.externalSecrets.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS),
route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } },
},
{
id: 'settings-source-control',
icon: 'git-branch',
label: i18n.baseText('settings.sourceControl.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.SOURCE_CONTROL),
route: { to: { name: VIEWS.SOURCE_CONTROL } },
},
{
id: 'settings-sso',
icon: 'user-lock',
label: i18n.baseText('settings.sso'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.SSO_SETTINGS),
route: { to: { name: VIEWS.SSO_SETTINGS } },
},
{
id: 'settings-ldap',
icon: 'network',
label: i18n.baseText('settings.ldap'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
route: { to: { name: VIEWS.LDAP_SETTINGS } },
},
{
id: 'settings-workersview',
icon: 'waypoints',
label: i18n.baseText('mainSidebar.workersView'),
position: 'top',
available:
settingsStore.isQueueModeEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'workersView:manage' } }),
route: { to: { name: VIEWS.WORKER_VIEW } },
},
];
menuItems.push({
id: 'settings-log-streaming',
icon: 'log-in',
label: i18n.baseText('settings.log-streaming'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS),
route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } },
});
menuItems.push({
id: 'settings-community-nodes',
icon: 'box',
label: i18n.baseText('settings.communityNodes'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.COMMUNITY_NODES),
route: { to: { name: VIEWS.COMMUNITY_NODES } },
});
menuItems.push({
id: 'settings-migration-report',
icon: 'list-checks',
label: i18n.baseText('settings.migrationReport'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.MIGRATION_REPORT),
route: { to: { name: VIEWS.MIGRATION_REPORT } },
});
// Append module-registered settings sidebar items.
const moduleItems = uiStore.settingsSidebarItems;
return menuItems.concat(moduleItems.filter((item) => !menuItems.some((m) => m.id === item.id)));
});
const visibleSettingsItems = computed(() => settingsItems.value.filter((item) => item.available));
return { settingsItems: visibleSettingsItems };
}
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { useSidebarLayout } from './useSidebarLayout';
// Mock UI Store
const mockUIStore = {
sidebarMenuCollapsed: false,
toggleSidebarMenuCollapse: vi.fn(),
};
vi.mock('../stores/ui.store', () => ({
useUIStore: () => mockUIStore,
}));
// Mock useLocalStorage
vi.mock('@vueuse/core', () => ({
useLocalStorage: vi.fn((_key: string, defaultValue: number) => ref(defaultValue)),
}));
// Mock constants
vi.mock('../constants', () => ({
LOCAL_STORAGE_SIDEBAR_WIDTH: 'sidebarWidth',
}));
describe('useSidebarLayout', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset UI store state
mockUIStore.sidebarMenuCollapsed = false;
});
describe('initial setup', () => {
it('should initialize with correct default width when not collapsed', () => {
mockUIStore.sidebarMenuCollapsed = false;
const { sidebarWidth } = useSidebarLayout();
expect(sidebarWidth.value).toBe(300);
});
it('should initialize with correct default width when collapsed', () => {
mockUIStore.sidebarMenuCollapsed = true;
const { sidebarWidth } = useSidebarLayout();
expect(sidebarWidth.value).toBe(42);
});
it('should return computed isCollapsed from store', () => {
mockUIStore.sidebarMenuCollapsed = true;
const { isCollapsed } = useSidebarLayout();
expect(isCollapsed.value).toBe(true);
});
it('should initialize isResizing as false', () => {
const { isResizing } = useSidebarLayout();
expect(isResizing.value).toBe(false);
});
});
describe('toggleCollapse', () => {
it('should call store toggle method', () => {
const { toggleCollapse } = useSidebarLayout();
toggleCollapse();
expect(mockUIStore.toggleSidebarMenuCollapse).toHaveBeenCalledTimes(1);
});
it('should set width to 200 when expanding (not collapsed after toggle)', () => {
mockUIStore.sidebarMenuCollapsed = false; // Will be true after toggle
const { toggleCollapse, sidebarWidth } = useSidebarLayout();
toggleCollapse();
expect(sidebarWidth.value).toBe(200);
});
it('should set width to 42 when collapsing (collapsed after toggle)', () => {
mockUIStore.sidebarMenuCollapsed = true; // Will be false after toggle
const { toggleCollapse, sidebarWidth } = useSidebarLayout();
toggleCollapse();
expect(sidebarWidth.value).toBe(42);
});
});
describe('resize state management', () => {
it('should set isResizing to true when resize starts', () => {
const { onResizeStart, isResizing } = useSidebarLayout();
onResizeStart();
expect(isResizing.value).toBe(true);
});
it('should set isResizing to false when resize ends', () => {
const { onResizeStart, onResizeEnd, isResizing } = useSidebarLayout();
// Start resize first
onResizeStart();
expect(isResizing.value).toBe(true);
// End resize
onResizeEnd();
expect(isResizing.value).toBe(false);
});
});
describe('onResize logic', () => {
it('should expand when collapsed and dragging right past threshold', () => {
mockUIStore.sidebarMenuCollapsed = true;
const { onResize } = useSidebarLayout();
onResize({ width: 250, x: 150 }); // x > 100
expect(mockUIStore.toggleSidebarMenuCollapse).toHaveBeenCalledTimes(1);
});
it('should not resize when collapsed and dragging right below threshold', () => {
mockUIStore.sidebarMenuCollapsed = true;
const { onResize, sidebarWidth } = useSidebarLayout();
const originalWidth = sidebarWidth.value;
onResize({ width: 250, x: 80 }); // x < 100
expect(sidebarWidth.value).toBe(originalWidth);
expect(mockUIStore.toggleSidebarMenuCollapse).not.toHaveBeenCalled();
});
it('should collapse when expanded and dragging left below threshold', () => {
mockUIStore.sidebarMenuCollapsed = false;
const { onResize } = useSidebarLayout();
onResize({ width: 250, x: 50 }); // x < 100
expect(mockUIStore.toggleSidebarMenuCollapse).toHaveBeenCalledTimes(1);
});
it('should update width when expanded and dragging within normal range', () => {
mockUIStore.sidebarMenuCollapsed = false;
const { onResize, sidebarWidth } = useSidebarLayout();
onResize({ width: 350, x: 200 });
expect(sidebarWidth.value).toBe(350);
});
it('should not update width when collapsed', () => {
mockUIStore.sidebarMenuCollapsed = true;
const { onResize, sidebarWidth } = useSidebarLayout();
const originalWidth = sidebarWidth.value;
onResize({ width: 350, x: 80 }); // Below threshold, should not expand
expect(sidebarWidth.value).toBe(originalWidth);
});
});
});
@@ -0,0 +1,57 @@
import { computed, ref } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useUIStore } from '../stores/ui.store';
import { LOCAL_STORAGE_SIDEBAR_WIDTH } from '../constants';
export function useSidebarLayout() {
const uiStore = useUIStore();
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
const sidebarWidth = useLocalStorage(LOCAL_STORAGE_SIDEBAR_WIDTH, isCollapsed.value ? 42 : 300);
const toggleCollapse = () => {
uiStore.toggleSidebarMenuCollapse();
if (!isCollapsed.value) {
sidebarWidth.value = 200;
} else {
sidebarWidth.value = 42;
}
};
const isResizing = ref(false);
function onResizeStart() {
isResizing.value = true;
}
function onResize(event: { width: number; x: number }) {
if (isCollapsed.value && event.x > 100) {
toggleCollapse();
return;
}
if (isCollapsed.value) {
return;
}
if (event.x < 100 && !isCollapsed.value) {
toggleCollapse();
return;
}
sidebarWidth.value = event.width;
}
function onResizeEnd() {
isResizing.value = false;
}
return {
isCollapsed,
toggleCollapse,
sidebarWidth,
isResizing,
onResizeStart,
onResize,
onResizeEnd,
};
}
@@ -27,3 +27,4 @@ export const LOCAL_STORAGE_CHAT_HUB_STATIC_SIDEBAR = (userId: string) =>
`${userId}_N8N_CHAT_HUB_STATIC_SIDEBAR`;
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_TOOLS = (userId: string) =>
`${userId}_N8N_CHAT_HUB_SELECTED_TOOLS`;
export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH';
@@ -27,7 +27,7 @@ $warning-tooltip-color: var(--color--danger);
$header-height: calc(var(--header--height) * 1px);
// sidebar
$sidebar-width: 65px;
$sidebar-width: 42px;
$sidebar-expanded-width: 200px;
$sidebar-inactive-color: var(--color--foreground--shade-2);
$sidebar-active-color: var(--color--primary);
@@ -94,13 +94,13 @@ describe('ProjectsNavigation', () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.myProjects = [...personalProjects, ...teamProjects];
const { getByRole, getAllByTestId, getByTestId } = renderComponent({
const { getAllByTestId, getByTestId, queryByText } = renderComponent({
props: {
collapsed: false,
},
});
expect(getByRole('heading', { level: 3, name: 'Projects' })).toBeVisible();
expect(queryByText('Projects')).toBeVisible();
expect(getByTestId('project-personal-menu-item')).toBeVisible();
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeVisible();
expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length);
@@ -109,32 +109,32 @@ describe('ProjectsNavigation', () => {
it('should not show "Projects" title when the menu is collapsed', async () => {
projectsStore.teamProjectsLimit = -1;
const { queryByRole } = renderComponent({
const { queryByText } = renderComponent({
props: {
collapsed: true,
},
});
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
expect(queryByText('Projects')).not.toBeInTheDocument();
});
it('should not show "Projects" title when the feature is not enabled', async () => {
projectsStore.teamProjectsLimit = 0;
const { queryByRole } = renderComponent({
const { queryByText } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
expect(queryByText('Projects')).not.toBeInTheDocument();
});
it('should show Personal project when folders are enabled but projects are disabled', async () => {
projectsStore.teamProjectsLimit = 0;
settingsStore.isFoldersFeatureEnabled = true;
const { queryByRole, getByTestId, queryByTestId } = renderComponent({
const { queryByText, getByTestId, queryByTestId } = renderComponent({
props: {
collapsed: false,
},
@@ -143,7 +143,7 @@ describe('ProjectsNavigation', () => {
// Personal project menu item should be visible
expect(getByTestId('project-personal-menu-item')).toBeVisible();
// Projects section should not be visible
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
expect(queryByText('Projects')).not.toBeInTheDocument();
expect(queryByTestId('project-plus-button')).not.toBeInTheDocument();
});
@@ -160,74 +160,17 @@ describe('ProjectsNavigation', () => {
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeInTheDocument();
});
it('should not show add first project button if there are projects already', async () => {
it('should projects section when there are projects', async () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.myProjects = [...teamProjects];
const { queryByTestId } = renderComponent({
const { getByText } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
});
it('should not show project plus button and add first project button if user cannot create projects', async () => {
projectsStore.teamProjectsLimit = 0;
const { queryByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByTestId('project-plus-button')).not.toBeInTheDocument();
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
});
it('should show project plus button and add first project button if user can create projects', async () => {
projectsStore.teamProjectsLimit = -1;
const { getByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(getByTestId('project-plus-button')).toBeVisible();
expect(getByTestId('add-first-project-button')).toBeVisible();
});
it('should show project plus button and add first project button in disabled state if user does not have permission', async () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.hasPermissionToCreateProjects = false;
const { getByTestId } = renderComponent({
props: {
collapsed: false,
},
});
const plusButton = getByTestId('project-plus-button');
const addFirstProjectButton = getByTestId('add-first-project-button');
expect(plusButton).toBeVisible();
expect(plusButton).toBeDisabled();
expect(addFirstProjectButton).toBeVisible();
expect(addFirstProjectButton).toBeDisabled();
});
it('should show add first project button without text when the menu is collapsed', async () => {
projectsStore.teamProjectsLimit = -1;
const { getByTestId } = renderComponent({
props: {
collapsed: true,
},
});
expect(getByTestId('add-first-project-button')).toBeVisible();
expect(getByTestId('add-first-project-button').classList.contains('collapsed')).toBe(true);
expect(getByText('Projects')).toBeVisible();
});
it('should not render shared menu item when only one verified user', async () => {
@@ -10,7 +10,7 @@ import { computed, onBeforeMount, onBeforeUnmount } from 'vue';
import { useProjectsStore } from '../projects.store';
import type { ProjectListItem } from '../projects.types';
import { N8nButton, N8nHeading, N8nMenuItem, N8nTooltip } from '@n8n/design-system';
import { N8nMenuItem, N8nText } from '@n8n/design-system';
type Props = {
collapsed: boolean;
@@ -26,7 +26,6 @@ const projectsStore = useProjectsStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.value);
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
const hasMultipleVerifiedUsers = computed(
@@ -75,10 +74,6 @@ const personalProject = computed<IMenuItem>(() => ({
},
}));
const showAddFirstProject = computed(
() => projectsStore.isTeamProjectFeatureEnabled && !displayProjects.value.length,
);
const activeTabId = computed(() => {
return (
(Array.isArray(projectsStore.projectNavActiveId)
@@ -104,7 +99,7 @@ onBeforeUnmount(() => {
<template>
<div :class="$style.projects">
<div class="home">
<div :class="[$style.home, props.collapsed ? $style.collapsed : '']">
<N8nMenuItem
:item="home"
:compact="props.collapsed"
@@ -129,31 +124,18 @@ onBeforeUnmount(() => {
data-test-id="project-shared-menu-item"
/>
</div>
<N8nHeading
v-if="!props.collapsed && projectsStore.isTeamProjectFeatureEnabled"
<N8nText
v-if="
!props.collapsed && projectsStore.isTeamProjectFeatureEnabled && displayProjects.length > 0
"
:class="[$style.projectsLabel]"
bold
size="small"
bold
role="heading"
color="text-light"
tag="h3"
>
<span>{{ locale.baseText('projects.menu.title') }}</span>
<N8nTooltip
v-if="projectsStore.canCreateProjects"
placement="right"
:disabled="projectsStore.hasPermissionToCreateProjects"
:content="locale.baseText('projects.create.permissionDenied')"
>
<N8nButton
icon="plus"
text
data-test-id="project-plus-button"
:disabled="isCreatingProject || !projectsStore.hasPermissionToCreateProjects"
:class="$style.plusBtn"
@click="globalEntityCreation.createProject('add_icon')"
/>
</N8nTooltip>
</N8nHeading>
{{ locale.baseText('projects.menu.title') }}
</N8nText>
<div
v-if="projectsStore.isTeamProjectFeatureEnabled || isFoldersFeatureEnabled"
:class="$style.projectItems"
@@ -170,28 +152,6 @@ onBeforeUnmount(() => {
data-test-id="project-menu-item"
/>
</div>
<N8nTooltip
v-if="showAddFirstProject"
placement="right"
:disabled="projectsStore.hasPermissionToCreateProjects"
:content="locale.baseText('projects.create.permissionDenied')"
>
<N8nButton
:class="[
$style.addFirstProjectBtn,
{
[$style.collapsed]: props.collapsed,
},
]"
:disabled="isCreatingProject || !projectsStore.hasPermissionToCreateProjects"
type="secondary"
icon="plus"
data-test-id="add-first-project-button"
@click="globalEntityCreation.createProject('add_first_project_button')"
>
<span>{{ locale.baseText('projects.menu.addFirstProject') }}</span>
</N8nButton>
</N8nTooltip>
</div>
</template>
@@ -208,7 +168,7 @@ onBeforeUnmount(() => {
}
.projectItems {
padding: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--3xs);
}
.upgradeLink {
@@ -222,8 +182,8 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
overflow: hidden;
box-sizing: border-box;
padding: 0 var(--spacing--sm);
margin-top: var(--spacing--md);
padding: 0 var(--spacing--xs);
margin-top: var(--spacing--2xs);
&.collapsed {
padding: 0;
@@ -241,20 +201,19 @@ onBeforeUnmount(() => {
.addFirstProjectBtn {
font-size: var(--font-size--xs);
margin: 0 var(--spacing--sm);
width: calc(100% - var(--spacing--sm) * 2);
margin: 0 var(--spacing--xs);
width: calc(100% - var(--spacing--xs) * 2);
&.collapsed {
> span:last-child {
display: none;
margin: 0 var(--spacing--sm) var(--spacing--md);
}
display: none;
}
}
.home {
padding: 0 var(--spacing--3xs) var(--spacing--2xs);
&.collapsed {
border-bottom: var(--border);
}
}
</style>
<style lang="scss" scoped>
.home {
padding: 0 var(--spacing--xs);
}
</style>
@@ -40,6 +40,10 @@ export class SidebarPage {
await this.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click();
}
getProjectButtonInUniversalAdd(): Locator {
return this.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' });
}
async addWorkflowFromUniversalAdd(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Workflow').click();
@@ -65,20 +69,28 @@ export class SidebarPage {
return this.page.getByTestId('add-first-project-button');
}
getUserMenu(): Locator {
return this.page.getByTestId('main-sidebar-user-menu');
getSettings(): Locator {
return this.page.getByTestId('main-sidebar-settings');
}
getLogoutMenuItem(): Locator {
return this.page.getByTestId('user-menu-item-logout');
return this.page.getByTestId('main-sidebar-log-out');
}
getAboutModal(): Locator {
return this.page.getByTestId('about-modal');
}
getHelp(): Locator {
return this.page.getByTestId('main-sidebar-help');
}
async clickHelpMenuItem(): Promise<void> {
await this.getHelp().click();
}
async clickAboutMenuItem(): Promise<void> {
await this.page.getByTestId('help').click();
await this.getHelp().click();
await this.page.getByTestId('about').click();
}
@@ -91,7 +103,7 @@ export class SidebarPage {
}
getAdminPanel(): Locator {
return this.page.getByTestId('cloud-admin');
return this.page.getByTestId('main-sidebar-cloud-admin');
}
getTrialBanner(): Locator {
@@ -99,16 +111,20 @@ export class SidebarPage {
}
getTemplatesLink(): Locator {
return this.page.getByTestId('templates').locator('a');
return this.page.getByTestId('main-sidebar-templates').locator('a');
}
async openUserMenu(): Promise<void> {
await this.getUserMenu().click();
getVersionUpdateItem(): Locator {
return this.page.getByTestId('version-update-cta-button');
}
async openSettings(): Promise<void> {
await this.getSettings().click();
}
async clickSignout(): Promise<void> {
await this.expand();
await this.openUserMenu();
await this.openSettings();
await this.getLogoutMenuItem().click();
}
@@ -122,13 +138,12 @@ export class SidebarPage {
}
async expand() {
const collapseButton = this.page.locator('#collapse-change-button');
const chevronRight = this.page.locator(
'#collapse-change-button svg[data-icon="chevron-right"]',
);
const logo = this.page.getByTestId('n8n-logo');
const isExpanded = await logo.isVisible();
await expect(collapseButton).toBeVisible();
if (await chevronRight.isVisible()) {
if (!isExpanded) {
const collapseButton = this.page.locator('#toggle-sidebar-button');
await expect(collapseButton).toBeVisible();
await collapseButton.click();
}
}
@@ -11,7 +11,7 @@ test.describe('User Management', () => {
INSTANCE_OWNER_CREDENTIALS.email,
INSTANCE_OWNER_CREDENTIALS.password,
);
await expect(n8n.sideBar.getUserMenu()).toBeVisible();
await expect(n8n.sideBar.getSettings()).toBeVisible();
});
test('should prevent non-owners to access UM settings', async ({ n8n }) => {
@@ -58,19 +58,11 @@ const requirements: TestRequirements = {
};
test.describe('Versions', () => {
test('should open updates panel', async ({ n8n, setupRequirements }) => {
test('should show updates in help section', async ({ n8n, setupRequirements }) => {
await setupRequirements(requirements);
await n8n.goHome();
await n8n.sideBar.expand();
await n8n.versions.openWhatsNewMenu();
await expect(n8n.versions.getVersionUpdatesPanelOpenButton()).toContainText(
'2 versions behind',
);
await n8n.versions.openVersionUpdatesPanel();
await expect(n8n.versions.getVersionCard()).toHaveCount(2);
await n8n.versions.closeVersionUpdatesPanel();
await expect(n8n.versions.getVersionUpdatesPanel()).toBeHidden();
await n8n.sideBar.clickHelpMenuItem();
await expect(n8n.sideBar.getVersionUpdateItem()).toContainText('Update (2 versions behind)');
});
});
@@ -31,6 +31,7 @@ test.describe('Become creator CTA', () => {
await setupRequirements(eligibleRequirements);
await n8n.goHome();
await n8n.sideBar.expand();
await n8n.sideBar.getHelp().click();
await expect(n8n.becomeCreatorCTA.getBecomeTemplateCreatorCta()).toBeVisible();
@@ -31,7 +31,8 @@ test.describe('Projects', () => {
test('should not show project add button and projects to a member if not invited to any project @auth:member', async ({
n8n,
}) => {
await expect(n8n.sideBar.getAddFirstProjectButton()).toBeDisabled();
await n8n.sideBar.universalAdd();
await expect(n8n.sideBar.getProjectButtonInUniversalAdd()).toContainClass('is-disabled');
await expect(n8n.sideBar.getProjectMenuItems()).toHaveCount(0);
});
@@ -24,14 +24,14 @@ test.describe('Focus panel', () => {
await expect(n8n.canvas.focusPanel.getMapper()).toBeVisible();
// Assert that mapper is closed but the Set node is still selected and shown in
await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } });
await n8n.canvas.canvasBody().click({ position: { x: 1, y: 1 } });
await expect(n8n.canvas.focusPanel.getMapper()).toBeHidden();
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toHaveText('Set');
await expect(n8n.canvas.selectedNodes()).toHaveCount(1);
// Assert that another click on canvas does de-select the Set node
await n8n.canvas.canvasBody().click({ position: { x: 0, y: 0 } });
await n8n.canvas.canvasBody().click({ position: { x: 1, y: 1 } });
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toBeHidden();
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);