From 0ba2043047dadfdccf96b30f5653886430d7eb54 Mon Sep 17 00:00:00 2001 From: Robert Squires Date: Wed, 3 Dec 2025 08:03:16 +0000 Subject: [PATCH] feat(editor): Sidebar redesign (#22395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Iván Ovejero Co-authored-by: Daria --- .../cli/src/workflows/workflow.service.ts | 9 +- .../integration/public-api/workflows.test.ts | 12 +- .../workflows/workflows.controller.test.ts | 14 +- .../src/components/N8nMenuItem/MenuItem.vue | 37 +- .../frontend/@n8n/i18n/src/locales/en.json | 7 +- .../BecomeTemplateCreatorCta.vue | 12 +- .../src/app/components/MainSidebar.test.ts | 133 +++++- .../src/app/components/MainSidebar.vue | 385 +++++++++++------- .../components/MainSidebarSourceControl.vue | 112 +++-- .../editor-ui/src/app/components/Modals.vue | 4 +- .../src/app/components/SettingsSidebar.vue | 141 +------ .../app/components/VersionUpdateCTA.test.ts | 26 +- .../src/app/components/VersionUpdateCTA.vue | 70 +--- .../src/app/composables/useSettingsItems.ts | 140 +++++++ .../app/composables/useSidebarLayout.test.ts | 163 ++++++++ .../src/app/composables/useSidebarLayout.ts | 57 +++ .../src/app/constants/localStorage.ts | 1 + .../editor-ui/src/app/css/_variables.scss | 2 +- .../components/ProjectNavigation.test.ts | 79 +--- .../projects/components/ProjectNavigation.vue | 89 ++-- .../testing/playwright/pages/SidebarPage.ts | 45 +- .../tests/ui/18-user-management.spec.ts | 2 +- .../playwright/tests/ui/36-versions.spec.ts | 14 +- .../tests/ui/37-become-creator-cta.spec.ts | 1 + .../playwright/tests/ui/39-projects.spec.ts | 3 +- .../tests/ui/54-focus-panel.spec.ts | 4 +- 26 files changed, 944 insertions(+), 618 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts create mode 100644 packages/frontend/editor-ui/src/app/composables/useSidebarLayout.test.ts create mode 100644 packages/frontend/editor-ui/src/app/composables/useSidebarLayout.ts diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 39669089314..db0d62fe11f 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -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( diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 97b24da0762..1705a64df35 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -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; diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 9f21c635c61..bf31974b2e0 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -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; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue index 41e96790ec5..cde4b1f019f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -81,7 +81,7 @@ const iconColor = computed(() => { :color="iconColor" >{{ item.icon.value }} - + {{ item.label }} @@ -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); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 4f126150dbd..9749d35d1af 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue b/packages/frontend/editor-ui/src/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue index 01e496135c4..1ae72b26c8c 100644 --- a/packages/frontend/editor-ui/src/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue +++ b/packages/frontend/editor-ui/src/app/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue @@ -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" >
-

+ {{ i18n.baseText('becomeCreator.text') }} -

+
{ 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 { diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts index f537b71d706..32a034e0383 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts @@ -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; let sourceControlStore: MockedStore; let versionsStore: MockedStore; let usersStore: MockedStore; +let templatesStore: MockedStore; +let personalizedTemplatesV2Store: MockedStore; +let personalizedTemplatesV3Store: MockedStore; +let templatesDataQualityStore: MockedStore; 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, + }, + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue index acfa1a7ab19..2af3abe5b5f 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue @@ -1,5 +1,5 @@