diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 691d2c047f..f9de0a28f8 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -74,6 +74,7 @@ "sanitize-html": "2.10.0", "vue": "^3.3.4", "vue-boring-avatars": "^1.3.0", + "vue-router": "^4.2.2", "xss": "^1.0.14" } } diff --git a/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue b/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue new file mode 100644 index 0000000000..d479bd5396 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/CondtionalRouterLink.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts b/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts new file mode 100644 index 0000000000..853e06f8a9 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/__tests__/ConditionalRouterLink.spec.ts @@ -0,0 +1,67 @@ +import { render } from '@testing-library/vue'; +import { beforeAll, describe } from 'vitest'; +import { createRouter, createWebHistory } from 'vue-router'; +import CondtionalRouterLink from '../CondtionalRouterLink.vue'; + +const slots = { + default: 'Button', +}; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + redirect: '/home', + }, + ], +}); + +describe('CondtionalRouterLink', () => { + beforeAll(async () => { + await router.push('/'); + + await router.isReady(); + }); + + it("renders router-link when 'to' prop is passed", () => { + const wrapper = render(CondtionalRouterLink, { + props: { + to: { name: 'home' }, + }, + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it("renders when 'href' attr is passed", () => { + const wrapper = render(CondtionalRouterLink, { + attrs: { + href: 'https://n8n.io', + target: '_blank', + }, + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders only the slot when neither to nor href is given', () => { + const wrapper = render(CondtionalRouterLink, { + slots, + global: { + plugins: [router], + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap b/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap new file mode 100644 index 0000000000..c316667540 --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/__tests__/__snapshots__/ConditionalRouterLink.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CondtionalRouterLink > renders when 'href' attr is passed 1`] = `"
Button
"`; + +exports[`CondtionalRouterLink > renders only the slot when neither to nor href is given 1`] = `"
Button
"`; + +exports[`CondtionalRouterLink > renders router-link when 'to' prop is passed 1`] = `"
Button
"`; diff --git a/packages/design-system/src/components/ConditionalRouterLink/index.ts b/packages/design-system/src/components/ConditionalRouterLink/index.ts new file mode 100644 index 0000000000..4b8b5b65ad --- /dev/null +++ b/packages/design-system/src/components/ConditionalRouterLink/index.ts @@ -0,0 +1,3 @@ +import CondtionalRouterLink from './CondtionalRouterLink.vue'; + +export default CondtionalRouterLink; diff --git a/packages/design-system/src/components/N8nMenu/Menu.stories.ts b/packages/design-system/src/components/N8nMenu/Menu.stories.ts index 970d55b62f..bca1eff3bd 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.stories.ts +++ b/packages/design-system/src/components/N8nMenu/Menu.stories.ts @@ -114,10 +114,9 @@ const menuItems = [ id: 'website', icon: 'globe', label: 'Website', - type: 'link', - properties: { + link: { href: 'https://www.n8n.io', - newWindow: true, + target: '_blank', }, position: 'bottom', }, @@ -140,10 +139,9 @@ const menuItems = [ id: 'quickstart', icon: 'video', label: 'Quickstart', - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', - newWindow: true, + target: '_blank', }, }, ], diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index 36e7dfbc98..9e4da3e1d6 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -59,6 +59,7 @@ import N8nMenuItem from '../N8nMenuItem'; import type { PropType } from 'vue'; import { defineComponent } from 'vue'; import type { IMenuItem, RouteObject } from '../../types'; +import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil'; export default defineComponent({ name: 'N8nMenu', @@ -128,14 +129,10 @@ export default defineComponent({ }, mounted() { if (this.mode === 'router') { - const found = this.items.find((item) => { - return ( - (Array.isArray(item.activateOnRouteNames) && - item.activateOnRouteNames.includes(this.currentRoute.name || '')) || - (Array.isArray(item.activateOnRoutePaths) && - item.activateOnRoutePaths.includes(this.currentRoute.path)) - ); - }); + const found = this.items.find((item) => + doesMenuItemMatchCurrentRoute(item, this.currentRoute), + ); + this.activeTab = found ? found.id : ''; } else { this.activeTab = this.items.length > 0 ? this.items[0].id : ''; @@ -145,19 +142,6 @@ export default defineComponent({ }, methods: { onSelect(item: IMenuItem): void { - if (item && item.type === 'link' && item.properties) { - const href: string = item.properties.href; - if (!href) { - return; - } - - if (item.properties.newWindow) { - window.open(href); - } else { - window.location.assign(item.properties.href); - } - } - if (this.mode === 'tabs') { this.activeTab = item.id; } diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts b/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts index 5ad0429634..bb8df005a9 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.stories.ts @@ -75,10 +75,9 @@ link.args = { id: 'website', icon: 'globe', label: 'Website', - type: 'link', - properties: { + link: { href: 'https://www.n8n.io', - newWindow: true, + target: '_blank', }, }, }; @@ -96,10 +95,9 @@ withChildren.args = { id: 'quickstart', icon: 'video', label: 'Quickstart', - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', - newWindow: true, + target: '_blank', }, }, ], diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 290b363b3d..603b833e30 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -40,37 +40,39 @@ :disabled="!compact" :show-after="tooltipDelay" > - - - {{ item.label }} - + - - - + + {{ item.label }} + + + + + @@ -81,7 +83,9 @@ import N8nTooltip from '../N8nTooltip'; import N8nIcon from '../N8nIcon'; import type { PropType } from 'vue'; import { defineComponent } from 'vue'; +import ConditionalRouterLink from '../ConditionalRouterLink'; import type { IMenuItem, RouteObject } from '../../types'; +import { doesMenuItemMatchCurrentRoute } from './routerUtil'; export default defineComponent({ name: 'N8nMenuItem', @@ -90,6 +94,7 @@ export default defineComponent({ ElMenuItem, N8nIcon, N8nTooltip, + ConditionalRouterLink, }, props: { item: { @@ -115,9 +120,11 @@ export default defineComponent({ }, activeTab: { type: String, + default: undefined, }, handleSelect: { type: Function as PropType<(item: IMenuItem) => void>, + default: undefined, }, }, computed: { @@ -151,18 +158,7 @@ export default defineComponent({ }, isActive(item: IMenuItem): boolean { if (this.mode === 'router') { - if (item.activateOnRoutePaths) { - return ( - Array.isArray(item.activateOnRoutePaths) && - item.activateOnRoutePaths.includes(this.currentRoute.path) - ); - } else if (item.activateOnRouteNames) { - return ( - Array.isArray(item.activateOnRouteNames) && - item.activateOnRouteNames.includes(this.currentRoute.name || '') - ); - } - return false; + return doesMenuItemMatchCurrentRoute(item, this.currentRoute); } else { return item.id === this.activeTab; } diff --git a/packages/design-system/src/components/N8nMenuItem/routerUtil.ts b/packages/design-system/src/components/N8nMenuItem/routerUtil.ts new file mode 100644 index 0000000000..64627db787 --- /dev/null +++ b/packages/design-system/src/components/N8nMenuItem/routerUtil.ts @@ -0,0 +1,42 @@ +import type { IMenuItem, RouteObject } from '@/types'; +import type { RouteLocationRaw } from 'vue-router'; + +/** + * Checks if the given menu item matches the current route. + */ +export function doesMenuItemMatchCurrentRoute(item: IMenuItem, currentRoute: RouteObject) { + let activateOnRouteNames: string[] = []; + if (Array.isArray(item.activateOnRouteNames)) { + activateOnRouteNames = item.activateOnRouteNames; + } else if (item.route && isNamedRouteLocation(item.route.to)) { + activateOnRouteNames = [item.route.to.name]; + } + + let activateOnRoutePaths: string[] = []; + if (Array.isArray(item.activateOnRoutePaths)) { + activateOnRoutePaths = item.activateOnRoutePaths; + } else if (item.route && isPathRouteLocation(item.route.to)) { + activateOnRoutePaths = [item.route.to.path]; + } + + return ( + activateOnRouteNames.includes(currentRoute.name ?? '') || + activateOnRoutePaths.includes(currentRoute.path) + ); +} + +function isPathRouteLocation(routeLocation?: RouteLocationRaw): routeLocation is { path: string } { + return ( + typeof routeLocation === 'object' && + 'path' in routeLocation && + typeof routeLocation.path === 'string' + ); +} + +function isNamedRouteLocation(routeLocation?: RouteLocationRaw): routeLocation is { name: string } { + return ( + typeof routeLocation === 'object' && + 'name' in routeLocation && + typeof routeLocation.name === 'string' + ); +} diff --git a/packages/design-system/src/types/menu.ts b/packages/design-system/src/types/menu.ts index c97bfdd1f2..4a0a853b02 100644 --- a/packages/design-system/src/types/menu.ts +++ b/packages/design-system/src/types/menu.ts @@ -1,4 +1,6 @@ import type { ElTooltipProps } from 'element-plus'; +import type { AnchorHTMLAttributes } from 'vue'; +import type { RouteLocationRaw, RouterLinkProps } from 'vue-router'; export type IMenuItem = { id: string; @@ -7,22 +9,32 @@ export type IMenuItem = { secondaryIcon?: { name: string; size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'; - tooltip?: ElTooltipProps; + tooltip?: Partial; }; customIconSize?: 'medium' | 'small'; available?: boolean; position?: 'top' | 'bottom'; - type?: 'default' | 'link'; - properties?: ILinkMenuItemProperties; - // For router menus populate only one of those arrays: - // If menu item can be activated on certain route names (easy mode) + + /** Use this for external links */ + link?: ILinkMenuItemProperties; + /** Use this for defining a vue-router target */ + route?: RouterLinkProps; + /** + * If given, item will be activated on these route names. Note that if + * route is provided, it will be highlighted automatically + */ activateOnRouteNames?: string[]; - // For more specific matching, we can use paths activateOnRoutePaths?: string[]; + children?: IMenuItem[]; }; +export type IRouteMenuItemProperties = { + route: RouteLocationRaw; +}; + export type ILinkMenuItemProperties = { href: string; - newWindow?: boolean; + target?: AnchorHTMLAttributes['target']; + rel?: AnchorHTMLAttributes['rel']; }; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 61fd657531..3861dbfce3 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1270,7 +1270,6 @@ export interface UIState { nodeViewOffsetPosition: XYPosition; nodeViewMoveInProgress: boolean; selectedNodes: INodeUi[]; - sidebarMenuItems: IMenuItem[]; nodeViewInitialized: boolean; addFirstStepOnLoad: boolean; executionSidebarAutoRefresh: boolean; diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index c183635e60..78be40f2ad 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -118,7 +118,6 @@ import { useUIStore } from '@/stores/ui.store'; import { useUsersStore } from '@/stores/users.store'; import { useVersionsStore } from '@/stores/versions.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { isNavigationFailure } from 'vue-router'; import ExecutionsUsage from '@/components/ExecutionsUsage.vue'; import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; @@ -205,38 +204,24 @@ export default defineComponent({ }, mainMenuItems(): IMenuItem[] { const items: IMenuItem[] = []; - const injectedItems = this.uiStore.sidebarMenuItems; const workflows: IMenuItem = { id: 'workflows', icon: 'network-wired', label: this.$locale.baseText('mainSidebar.workflows'), position: 'top', - activateOnRouteNames: [VIEWS.WORKFLOWS], + route: { to: { name: VIEWS.WORKFLOWS } }, + secondaryIcon: this.sourceControlStore.preferences.branchReadOnly + ? { + name: 'lock', + tooltip: { + content: this.$locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip'), + }, + } + : undefined, }; - if (this.sourceControlStore.preferences.branchReadOnly) { - workflows.secondaryIcon = { - name: 'lock', - tooltip: { - content: this.$locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip'), - }, - }; - } - - if (injectedItems && injectedItems.length > 0) { - for (const item of injectedItems) { - items.push({ - id: item.id, - icon: item.icon || '', - label: item.label || '', - position: item.position, - type: item.properties?.href ? 'link' : 'regular', - properties: item.properties, - } as IMenuItem); - } - } - + const defaultSettingsRoute = this.findFirstAccessibleSettingsRoute(); const regularItems: IMenuItem[] = [ workflows, { @@ -245,7 +230,7 @@ export default defineComponent({ label: this.$locale.baseText('mainSidebar.templates'), position: 'top', available: this.settingsStore.isTemplatesEnabled, - activateOnRouteNames: [VIEWS.TEMPLATES], + route: { to: { name: VIEWS.TEMPLATES } }, }, { id: 'credentials', @@ -253,7 +238,7 @@ export default defineComponent({ label: this.$locale.baseText('mainSidebar.credentials'), customIconSize: 'medium', position: 'top', - activateOnRouteNames: [VIEWS.CREDENTIALS], + route: { to: { name: VIEWS.CREDENTIALS } }, }, { id: 'variables', @@ -261,18 +246,17 @@ export default defineComponent({ label: this.$locale.baseText('mainSidebar.variables'), customIconSize: 'medium', position: 'top', - activateOnRouteNames: [VIEWS.VARIABLES], + route: { to: { name: VIEWS.VARIABLES } }, }, { id: 'executions', icon: 'tasks', label: this.$locale.baseText('mainSidebar.executions'), position: 'top', - activateOnRouteNames: [VIEWS.EXECUTIONS], + route: { to: { name: VIEWS.EXECUTIONS } }, }, { id: 'cloud-admin', - type: 'link', position: 'bottom', label: 'Admin Panel', icon: 'home', @@ -285,6 +269,7 @@ export default defineComponent({ position: 'bottom', available: this.canUserAccessSettings && this.usersStore.currentUser !== null, activateOnRouteNames: [VIEWS.USERS_SETTINGS, VIEWS.API_SETTINGS, VIEWS.PERSONAL_SETTINGS], + route: { to: defaultSettingsRoute }, }, { id: 'help', @@ -296,40 +281,36 @@ export default defineComponent({ id: 'quickstart', icon: 'video', label: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'), - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4', - newWindow: true, + target: '_blank', }, }, { id: 'docs', icon: 'book', label: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'), - type: 'link', - properties: { + link: { href: 'https://docs.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar', - newWindow: true, + target: '_blank', }, }, { id: 'forum', icon: 'users', label: this.$locale.baseText('mainSidebar.helpMenuItems.forum'), - type: 'link', - properties: { + link: { href: 'https://community.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar', - newWindow: true, + target: '_blank', }, }, { id: 'examples', icon: 'graduation-cap', label: this.$locale.baseText('mainSidebar.helpMenuItems.course'), - type: 'link', - properties: { + link: { href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4', - newWindow: true, + target: '_blank', }, }, { @@ -421,46 +402,6 @@ export default defineComponent({ }, async handleSelect(key: string) { switch (key) { - case 'workflows': { - if (this.$router.currentRoute.value.name !== VIEWS.WORKFLOWS) { - this.goToRoute({ name: VIEWS.WORKFLOWS }); - } - break; - } - case 'templates': { - if (this.$router.currentRoute.value.name !== VIEWS.TEMPLATES) { - this.goToRoute({ name: VIEWS.TEMPLATES }); - } - break; - } - case 'credentials': { - if (this.$router.currentRoute.value.name !== VIEWS.CREDENTIALS) { - this.goToRoute({ name: VIEWS.CREDENTIALS }); - } - break; - } - case 'variables': { - if (this.$router.currentRoute.value.name !== VIEWS.VARIABLES) { - this.goToRoute({ name: VIEWS.VARIABLES }); - } - break; - } - case 'executions': { - if (this.$router.currentRoute.value.name !== VIEWS.EXECUTIONS) { - this.goToRoute({ name: VIEWS.EXECUTIONS }); - } - break; - } - case 'settings': { - const defaultRoute = this.findFirstAccessibleSettingsRoute(); - if (defaultRoute) { - const route = this.$router.resolve({ name: defaultRoute }); - if (this.$router.currentRoute.value.name !== defaultRoute) { - this.goToRoute(route.path); - } - } - break; - } case 'about': { this.trackHelpItemClick('about'); this.uiStore.openModal(ABOUT_MODAL_KEY); @@ -481,25 +422,18 @@ export default defineComponent({ break; } }, - goToRoute(route: string | { name: string }) { - this.$router.push(route).catch((failure) => { - console.log(failure); - // Catch navigation failures caused by route guards - if (!isNavigationFailure(failure)) { - console.error(failure); - } - }); - }, findFirstAccessibleSettingsRoute() { const settingsRoutes = this.$router .getRoutes() .find((route) => route.path === '/settings')! - .children.map((route) => route.name || ''); + .children.map((route) => route.name ?? ''); - let defaultSettingsRoute = null; + let defaultSettingsRoute = { name: VIEWS.USERS_SETTINGS }; for (const route of settingsRoutes) { if (this.canUserAccessRouteByName(route.toString())) { - defaultSettingsRoute = route; + defaultSettingsRoute = { + name: route.toString() as VIEWS, + }; break; } } diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 84821d425d..6a7c1e03c3 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -49,7 +49,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.usageAndPlan.title'), position: 'top', available: this.canAccessUsageAndPlan(), - activateOnRouteNames: [VIEWS.USAGE], + route: { to: { name: VIEWS.USAGE } }, }, { id: 'settings-personal', @@ -57,7 +57,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.personal'), position: 'top', available: this.canAccessPersonalSettings(), - activateOnRouteNames: [VIEWS.PERSONAL_SETTINGS], + route: { to: { name: VIEWS.PERSONAL_SETTINGS } }, }, { id: 'settings-users', @@ -65,7 +65,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.users'), position: 'top', available: this.canAccessUsersSettings(), - activateOnRouteNames: [VIEWS.USERS_SETTINGS], + route: { to: { name: VIEWS.USERS_SETTINGS } }, }, { id: 'settings-api', @@ -73,7 +73,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.n8napi'), position: 'top', available: this.canAccessApiSettings(), - activateOnRouteNames: [VIEWS.API_SETTINGS], + route: { to: { name: VIEWS.API_SETTINGS } }, }, { id: 'settings-external-secrets', @@ -81,10 +81,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.externalSecrets.title'), position: 'top', available: this.canAccessExternalSecrets(), - activateOnRouteNames: [ - VIEWS.EXTERNAL_SECRETS_SETTINGS, - VIEWS.EXTERNAL_SECRETS_PROVIDER_SETTINGS, - ], + route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } }, }, { id: 'settings-audit-logs', @@ -92,7 +89,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.auditLogs.title'), position: 'top', available: this.canAccessAuditLogs(), - activateOnRouteNames: [VIEWS.AUDIT_LOGS], + route: { to: { name: VIEWS.AUDIT_LOGS } }, }, { id: 'settings-source-control', @@ -100,7 +97,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.sourceControl.title'), position: 'top', available: this.canAccessSourceControl(), - activateOnRouteNames: [VIEWS.SOURCE_CONTROL], + route: { to: { name: VIEWS.SOURCE_CONTROL } }, }, { id: 'settings-sso', @@ -108,7 +105,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.sso'), position: 'top', available: this.canAccessSso(), - activateOnRouteNames: [VIEWS.SSO_SETTINGS], + route: { to: { name: VIEWS.SSO_SETTINGS } }, }, { id: 'settings-ldap', @@ -116,7 +113,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.ldap'), position: 'top', available: this.canAccessLdapSettings(), - activateOnRouteNames: [VIEWS.LDAP_SETTINGS], + route: { to: { name: VIEWS.LDAP_SETTINGS } }, }, { id: 'settings-workersview', @@ -126,7 +123,7 @@ export default defineComponent({ available: this.settingsStore.isQueueModeEnabled && hasPermission(['rbac'], { rbac: { scope: 'workersView:manage' } }), - activateOnRouteNames: [VIEWS.WORKER_VIEW], + route: { to: { name: VIEWS.WORKER_VIEW } }, }, ]; @@ -134,7 +131,7 @@ export default defineComponent({ if (item.uiLocations.includes('settings')) { menuItems.push({ id: item.id, - icon: item.icon || 'question', + icon: item.icon ?? 'question', label: this.$locale.baseText(item.featureName as BaseTextKey), position: 'top', available: true, @@ -149,7 +146,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.log-streaming'), position: 'top', available: this.canAccessLogStreamingSettings(), - activateOnRouteNames: [VIEWS.LOG_STREAMING_SETTINGS], + route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } }, }); menuItems.push({ @@ -158,7 +155,7 @@ export default defineComponent({ label: this.$locale.baseText('settings.communityNodes'), position: 'top', available: this.canAccessCommunityNodes(), - activateOnRouteNames: [VIEWS.COMMUNITY_NODES], + route: { to: { name: VIEWS.COMMUNITY_NODES } }, }); return menuItems; @@ -211,51 +208,10 @@ export default defineComponent({ }, async handleSelect(key: string) { switch (key) { - case 'settings-personal': - await this.navigateTo(VIEWS.PERSONAL_SETTINGS); - break; - case 'settings-users': - await this.navigateTo(VIEWS.USERS_SETTINGS); - break; - case 'settings-api': - await this.navigateTo(VIEWS.API_SETTINGS); - break; - case 'settings-ldap': - await this.navigateTo(VIEWS.LDAP_SETTINGS); - break; - case 'settings-log-streaming': - await this.navigateTo(VIEWS.LOG_STREAMING_SETTINGS); - break; case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud case 'logging': this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {}); break; - case 'settings-community-nodes': - await this.navigateTo(VIEWS.COMMUNITY_NODES); - break; - case 'settings-usage-and-plan': - await this.navigateTo(VIEWS.USAGE); - break; - case 'settings-sso': - await this.navigateTo(VIEWS.SSO_SETTINGS); - break; - case 'settings-external-secrets': - await this.navigateTo(VIEWS.EXTERNAL_SECRETS_SETTINGS); - break; - case 'settings-source-control': - if (this.$router.currentRoute.name !== VIEWS.SOURCE_CONTROL) { - void this.$router.push({ name: VIEWS.SOURCE_CONTROL }); - } - break; - case 'settings-audit-logs': - if (this.$router.currentRoute.name !== VIEWS.AUDIT_LOGS) { - void this.$router.push({ name: VIEWS.AUDIT_LOGS }); - } - break; - case 'settings-workersview': { - await this.navigateTo(VIEWS.WORKER_VIEW); - break; - } default: break; } diff --git a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts index e66b6d43b3..ad20ade194 100644 --- a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts +++ b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts @@ -16,6 +16,7 @@ vi.mock('vue-router', () => ({ useRoute: vi.fn().mockReturnValue({ name: VIEWS.WORKFLOW_EXECUTIONS, }), + RouterLink: vi.fn(), })); let pinia: ReturnType; diff --git a/packages/editor-ui/src/components/__tests__/RBAC.test.ts b/packages/editor-ui/src/components/__tests__/RBAC.test.ts index 566fe71294..63bd7a1a58 100644 --- a/packages/editor-ui/src/components/__tests__/RBAC.test.ts +++ b/packages/editor-ui/src/components/__tests__/RBAC.test.ts @@ -9,6 +9,7 @@ vi.mock('vue-router', () => ({ path: '/workflows', params: {}, })), + RouterLink: vi.fn(), })); vi.mock('@/stores/rbac.store', () => ({ diff --git a/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts b/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts index a035654b1f..304902aed2 100644 --- a/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts +++ b/packages/editor-ui/src/composables/__tests__/useHistoryHelper.test.ts @@ -31,6 +31,7 @@ vi.mock('@/stores/ui.store', () => { }); vi.mock('vue-router', () => ({ useRoute: () => ({}), + RouterLink: vi.fn(), })); const TestComponent = defineComponent({ diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 0e4bc8ffa9..c8cb1c9d25 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -7,7 +7,7 @@ import type { RouteLocationRaw, RouteLocationNormalized, } from 'vue-router'; -import { createRouter, createWebHistory } from 'vue-router'; +import { createRouter, createWebHistory, isNavigationFailure } from 'vue-router'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useSettingsStore } from '@/stores/settings.store'; import { useTemplatesStore } from '@/stores/templates.store'; @@ -784,73 +784,89 @@ const router = createRouter({ }); router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => { - /** - * Initialize application core - * This step executes before first route is loaded and is required for permission checks - */ + try { + /** + * Initialize application core + * This step executes before first route is loaded and is required for permission checks + */ - await initializeCore(); + await initializeCore(); - /** - * Redirect to setup page. User should be redirected to this only once - */ + /** + * Redirect to setup page. User should be redirected to this only once + */ - const settingsStore = useSettingsStore(); - if (settingsStore.showSetupPage) { - if (to.name === VIEWS.SETUP) { - return next(); + const settingsStore = useSettingsStore(); + if (settingsStore.showSetupPage) { + if (to.name === VIEWS.SETUP) { + return next(); + } + + return next({ name: VIEWS.SETUP }); } - return next({ name: VIEWS.SETUP }); - } + /** + * Verify user permissions for current route + */ - /** - * Verify user permissions for current route - */ + const routeMiddleware = to.meta?.middleware ?? []; + const routeMiddlewareOptions = to.meta?.middlewareOptions ?? {}; + for (const middlewareName of routeMiddleware) { + let nextCalled = false; + const middlewareNext = ((location: RouteLocationRaw): void => { + next(location); + nextCalled = true; + }) as NavigationGuardNext; - const routeMiddleware = to.meta?.middleware ?? []; - const routeMiddlewareOptions = to.meta?.middlewareOptions ?? {}; - for (const middlewareName of routeMiddleware) { - let nextCalled = false; - const middlewareNext = ((location: RouteLocationRaw): void => { - next(location); - nextCalled = true; - }) as NavigationGuardNext; + const middlewareOptions = routeMiddlewareOptions[middlewareName]; + const middlewareFn = middleware[middlewareName] as RouterMiddleware; + await middlewareFn(to, from, middlewareNext, middlewareOptions); - const middlewareOptions = routeMiddlewareOptions[middlewareName]; - const middlewareFn = middleware[middlewareName] as RouterMiddleware; - await middlewareFn(to, from, middlewareNext, middlewareOptions); + if (nextCalled) { + return; + } + } - if (nextCalled) { - return; + return next(); + } catch (failure) { + if (isNavigationFailure(failure)) { + console.log(failure); + } else { + console.error(failure); } } - - return next(); }); router.afterEach((to, from) => { - const telemetry = useTelemetry(); - const uiStore = useUIStore(); - const templatesStore = useTemplatesStore(); + try { + const telemetry = useTelemetry(); + const uiStore = useUIStore(); + const templatesStore = useTemplatesStore(); - /** - * Run external hooks - */ + /** + * Run external hooks + */ - void useExternalHooks().run('main.routeChange', { from, to }); + void useExternalHooks().run('main.routeChange', { from, to }); - /** - * Track current view for telemetry - */ + /** + * Track current view for telemetry + */ - uiStore.currentView = (to.name as string) ?? ''; - if (to.meta?.templatesEnabled) { - templatesStore.setSessionId(); - } else { - templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages + uiStore.currentView = (to.name as string) ?? ''; + if (to.meta?.templatesEnabled) { + templatesStore.setSessionId(); + } else { + templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages + } + telemetry.page(to); + } catch (failure) { + if (isNavigationFailure(failure)) { + console.log(failure); + } else { + console.error(failure); + } } - telemetry.page(to); }); export default router; diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index bfb12f54b8..fac4806621 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -44,7 +44,6 @@ import type { CloudUpdateLinkSourceType, CurlToJSONResponse, IFakeDoorLocation, - IMenuItem, INodeUi, IOnboardingCallPrompt, IUser, @@ -176,7 +175,6 @@ export const useUIStore = defineStore(STORES.UI, { nodeViewOffsetPosition: [0, 0], nodeViewMoveInProgress: false, selectedNodes: [], - sidebarMenuItems: [], nodeViewInitialized: false, addFirstStepOnLoad: false, executionSidebarAutoRefresh: true, @@ -528,10 +526,6 @@ export const useUIStore = defineStore(STORES.UI, { resetSelectedNodes(): void { this.selectedNodes = []; }, - addSidebarMenuItems(menuItems: IMenuItem[]) { - const updated = this.sidebarMenuItems.concat(menuItems); - this.sidebarMenuItems = updated; - }, setCurlCommand(payload: { name: string; command: string }): void { this.modals[payload.name] = { ...this.modals[payload.name], diff --git a/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts index 13158b217e..148a11083e 100644 --- a/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts +++ b/packages/editor-ui/src/views/__tests__/SamlOnboarding.test.ts @@ -14,6 +14,7 @@ vi.mock('vue-router', () => { useRouter: () => ({ push, }), + RouterLink: vi.fn(), }; }); diff --git a/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts b/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts index ad39e22bcc..a0f9fa2bde 100644 --- a/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts +++ b/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts @@ -33,6 +33,7 @@ vi.mock('vue-router', () => { replace, resolve, }), + RouterLink: vi.fn(), }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fb9308c1c..5a583c874f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -867,6 +867,9 @@ importers: vue-boring-avatars: specifier: ^1.3.0 version: 1.3.0(vue@3.3.4) + vue-router: + specifier: ^4.2.2 + version: 4.2.2(vue@3.3.4) xss: specifier: ^1.0.14 version: 1.0.14