mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
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:
co-authored by
Iván Ovejero
Daria
parent
f69721ffec
commit
0ba2043047
@@ -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",
|
||||
|
||||
+6
-6
@@ -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);
|
||||
|
||||
+11
-68
@@ -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 () => {
|
||||
|
||||
+24
-65
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user