fix(editor): Use web native <a> element in nav menus (#8385)

This commit is contained in:
Tomi Turtiainen 2024-01-19 12:52:39 +02:00 committed by GitHub
parent 6fcf5ddcdd
commit e606e841ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 343 additions and 289 deletions

View file

@ -74,6 +74,7 @@
"sanitize-html": "2.10.0", "sanitize-html": "2.10.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-boring-avatars": "^1.3.0", "vue-boring-avatars": "^1.3.0",
"vue-router": "^4.2.2",
"xss": "^1.0.14" "xss": "^1.0.14"
} }
} }

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* Component that renders either a RouterLink or a normal anchor tag or
* just the slot content based on whether the `to` or `href` prop is
* passed or not.
*/
import { useAttrs } from 'vue';
import type { RouterLinkProps } from 'vue-router';
import { RouterLink } from 'vue-router';
defineOptions({
name: 'ConditionalRouterLink',
inheritAttrs: false,
});
const props = defineProps({
// @ts-expect-error TS doesn't understand this but it works
...RouterLink.props,
// Make to optional
to: {
type: [String, Object] as unknown as () => string | RouterLinkProps['to'] | undefined,
default: undefined,
},
// <a> element "props" are passed as attributes
}) as Partial<RouterLinkProps>;
const attrs = useAttrs();
</script>
<template>
<div>
<RouterLink v-if="props.to" v-bind="props" :to="props.to">
<slot />
</RouterLink>
<a v-else-if="attrs.href" v-bind="attrs">
<slot />
</a>
<slot v-else />
</div>
</template>

View file

@ -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 <a> 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();
});
});

View file

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CondtionalRouterLink > renders <a> when 'href' attr is passed 1`] = `"<div><a href="https://n8n.io" target="_blank">Button</a></div>"`;
exports[`CondtionalRouterLink > renders only the slot when neither to nor href is given 1`] = `"<div>Button</div>"`;
exports[`CondtionalRouterLink > renders router-link when 'to' prop is passed 1`] = `"<div><a href="/" class="">Button</a></div>"`;

View file

@ -0,0 +1,3 @@
import CondtionalRouterLink from './CondtionalRouterLink.vue';
export default CondtionalRouterLink;

View file

@ -114,10 +114,9 @@ const menuItems = [
id: 'website', id: 'website',
icon: 'globe', icon: 'globe',
label: 'Website', label: 'Website',
type: 'link', link: {
properties: {
href: 'https://www.n8n.io', href: 'https://www.n8n.io',
newWindow: true, target: '_blank',
}, },
position: 'bottom', position: 'bottom',
}, },
@ -140,10 +139,9 @@ const menuItems = [
id: 'quickstart', id: 'quickstart',
icon: 'video', icon: 'video',
label: 'Quickstart', label: 'Quickstart',
type: 'link', link: {
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true, target: '_blank',
}, },
}, },
], ],

View file

@ -59,6 +59,7 @@ import N8nMenuItem from '../N8nMenuItem';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import type { IMenuItem, RouteObject } from '../../types'; import type { IMenuItem, RouteObject } from '../../types';
import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil';
export default defineComponent({ export default defineComponent({
name: 'N8nMenu', name: 'N8nMenu',
@ -128,14 +129,10 @@ export default defineComponent({
}, },
mounted() { mounted() {
if (this.mode === 'router') { if (this.mode === 'router') {
const found = this.items.find((item) => { const found = this.items.find((item) =>
return ( doesMenuItemMatchCurrentRoute(item, this.currentRoute),
(Array.isArray(item.activateOnRouteNames) &&
item.activateOnRouteNames.includes(this.currentRoute.name || '')) ||
(Array.isArray(item.activateOnRoutePaths) &&
item.activateOnRoutePaths.includes(this.currentRoute.path))
); );
});
this.activeTab = found ? found.id : ''; this.activeTab = found ? found.id : '';
} else { } else {
this.activeTab = this.items.length > 0 ? this.items[0].id : ''; this.activeTab = this.items.length > 0 ? this.items[0].id : '';
@ -145,19 +142,6 @@ export default defineComponent({
}, },
methods: { methods: {
onSelect(item: IMenuItem): void { 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') { if (this.mode === 'tabs') {
this.activeTab = item.id; this.activeTab = item.id;
} }

View file

@ -75,10 +75,9 @@ link.args = {
id: 'website', id: 'website',
icon: 'globe', icon: 'globe',
label: 'Website', label: 'Website',
type: 'link', link: {
properties: {
href: 'https://www.n8n.io', href: 'https://www.n8n.io',
newWindow: true, target: '_blank',
}, },
}, },
}; };
@ -96,10 +95,9 @@ withChildren.args = {
id: 'quickstart', id: 'quickstart',
icon: 'video', icon: 'video',
label: 'Quickstart', label: 'Quickstart',
type: 'link', link: {
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok', href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
newWindow: true, target: '_blank',
}, },
}, },
], ],

View file

@ -40,6 +40,7 @@
:disabled="!compact" :disabled="!compact"
:show-after="tooltipDelay" :show-after="tooltipDelay"
> >
<ConditionalRouterLink v-bind="item.route ?? item.link">
<ElMenuItem <ElMenuItem
:id="item.id" :id="item.id"
:class="{ :class="{
@ -71,6 +72,7 @@
<N8nIcon :icon="item.secondaryIcon.name" :size="item.secondaryIcon.size || 'small'" /> <N8nIcon :icon="item.secondaryIcon.name" :size="item.secondaryIcon.size || 'small'" />
</N8nTooltip> </N8nTooltip>
</ElMenuItem> </ElMenuItem>
</ConditionalRouterLink>
</N8nTooltip> </N8nTooltip>
</div> </div>
</template> </template>
@ -81,7 +83,9 @@ import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import ConditionalRouterLink from '../ConditionalRouterLink';
import type { IMenuItem, RouteObject } from '../../types'; import type { IMenuItem, RouteObject } from '../../types';
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
export default defineComponent({ export default defineComponent({
name: 'N8nMenuItem', name: 'N8nMenuItem',
@ -90,6 +94,7 @@ export default defineComponent({
ElMenuItem, ElMenuItem,
N8nIcon, N8nIcon,
N8nTooltip, N8nTooltip,
ConditionalRouterLink,
}, },
props: { props: {
item: { item: {
@ -115,9 +120,11 @@ export default defineComponent({
}, },
activeTab: { activeTab: {
type: String, type: String,
default: undefined,
}, },
handleSelect: { handleSelect: {
type: Function as PropType<(item: IMenuItem) => void>, type: Function as PropType<(item: IMenuItem) => void>,
default: undefined,
}, },
}, },
computed: { computed: {
@ -151,18 +158,7 @@ export default defineComponent({
}, },
isActive(item: IMenuItem): boolean { isActive(item: IMenuItem): boolean {
if (this.mode === 'router') { if (this.mode === 'router') {
if (item.activateOnRoutePaths) { return doesMenuItemMatchCurrentRoute(item, this.currentRoute);
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;
} else { } else {
return item.id === this.activeTab; return item.id === this.activeTab;
} }

View file

@ -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'
);
}

View file

@ -1,4 +1,6 @@
import type { ElTooltipProps } from 'element-plus'; import type { ElTooltipProps } from 'element-plus';
import type { AnchorHTMLAttributes } from 'vue';
import type { RouteLocationRaw, RouterLinkProps } from 'vue-router';
export type IMenuItem = { export type IMenuItem = {
id: string; id: string;
@ -7,22 +9,32 @@ export type IMenuItem = {
secondaryIcon?: { secondaryIcon?: {
name: string; name: string;
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'; size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge';
tooltip?: ElTooltipProps; tooltip?: Partial<ElTooltipProps>;
}; };
customIconSize?: 'medium' | 'small'; customIconSize?: 'medium' | 'small';
available?: boolean; available?: boolean;
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
type?: 'default' | 'link';
properties?: ILinkMenuItemProperties; /** Use this for external links */
// For router menus populate only one of those arrays: link?: ILinkMenuItemProperties;
// If menu item can be activated on certain route names (easy mode) /** 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[]; activateOnRouteNames?: string[];
// For more specific matching, we can use paths
activateOnRoutePaths?: string[]; activateOnRoutePaths?: string[];
children?: IMenuItem[]; children?: IMenuItem[];
}; };
export type IRouteMenuItemProperties = {
route: RouteLocationRaw;
};
export type ILinkMenuItemProperties = { export type ILinkMenuItemProperties = {
href: string; href: string;
newWindow?: boolean; target?: AnchorHTMLAttributes['target'];
rel?: AnchorHTMLAttributes['rel'];
}; };

View file

@ -1270,7 +1270,6 @@ export interface UIState {
nodeViewOffsetPosition: XYPosition; nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean; nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[]; selectedNodes: INodeUi[];
sidebarMenuItems: IMenuItem[];
nodeViewInitialized: boolean; nodeViewInitialized: boolean;
addFirstStepOnLoad: boolean; addFirstStepOnLoad: boolean;
executionSidebarAutoRefresh: boolean; executionSidebarAutoRefresh: boolean;

View file

@ -118,7 +118,6 @@ import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useVersionsStore } from '@/stores/versions.store'; import { useVersionsStore } from '@/stores/versions.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { isNavigationFailure } from 'vue-router';
import ExecutionsUsage from '@/components/ExecutionsUsage.vue'; import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue'; import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
@ -205,38 +204,24 @@ export default defineComponent({
}, },
mainMenuItems(): IMenuItem[] { mainMenuItems(): IMenuItem[] {
const items: IMenuItem[] = []; const items: IMenuItem[] = [];
const injectedItems = this.uiStore.sidebarMenuItems;
const workflows: IMenuItem = { const workflows: IMenuItem = {
id: 'workflows', id: 'workflows',
icon: 'network-wired', icon: 'network-wired',
label: this.$locale.baseText('mainSidebar.workflows'), label: this.$locale.baseText('mainSidebar.workflows'),
position: 'top', position: 'top',
activateOnRouteNames: [VIEWS.WORKFLOWS], route: { to: { name: VIEWS.WORKFLOWS } },
}; secondaryIcon: this.sourceControlStore.preferences.branchReadOnly
? {
if (this.sourceControlStore.preferences.branchReadOnly) {
workflows.secondaryIcon = {
name: 'lock', name: 'lock',
tooltip: { tooltip: {
content: this.$locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip'), content: this.$locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip'),
}, },
}
: undefined,
}; };
}
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[] = [ const regularItems: IMenuItem[] = [
workflows, workflows,
{ {
@ -245,7 +230,7 @@ export default defineComponent({
label: this.$locale.baseText('mainSidebar.templates'), label: this.$locale.baseText('mainSidebar.templates'),
position: 'top', position: 'top',
available: this.settingsStore.isTemplatesEnabled, available: this.settingsStore.isTemplatesEnabled,
activateOnRouteNames: [VIEWS.TEMPLATES], route: { to: { name: VIEWS.TEMPLATES } },
}, },
{ {
id: 'credentials', id: 'credentials',
@ -253,7 +238,7 @@ export default defineComponent({
label: this.$locale.baseText('mainSidebar.credentials'), label: this.$locale.baseText('mainSidebar.credentials'),
customIconSize: 'medium', customIconSize: 'medium',
position: 'top', position: 'top',
activateOnRouteNames: [VIEWS.CREDENTIALS], route: { to: { name: VIEWS.CREDENTIALS } },
}, },
{ {
id: 'variables', id: 'variables',
@ -261,18 +246,17 @@ export default defineComponent({
label: this.$locale.baseText('mainSidebar.variables'), label: this.$locale.baseText('mainSidebar.variables'),
customIconSize: 'medium', customIconSize: 'medium',
position: 'top', position: 'top',
activateOnRouteNames: [VIEWS.VARIABLES], route: { to: { name: VIEWS.VARIABLES } },
}, },
{ {
id: 'executions', id: 'executions',
icon: 'tasks', icon: 'tasks',
label: this.$locale.baseText('mainSidebar.executions'), label: this.$locale.baseText('mainSidebar.executions'),
position: 'top', position: 'top',
activateOnRouteNames: [VIEWS.EXECUTIONS], route: { to: { name: VIEWS.EXECUTIONS } },
}, },
{ {
id: 'cloud-admin', id: 'cloud-admin',
type: 'link',
position: 'bottom', position: 'bottom',
label: 'Admin Panel', label: 'Admin Panel',
icon: 'home', icon: 'home',
@ -285,6 +269,7 @@ export default defineComponent({
position: 'bottom', position: 'bottom',
available: this.canUserAccessSettings && this.usersStore.currentUser !== null, available: this.canUserAccessSettings && this.usersStore.currentUser !== null,
activateOnRouteNames: [VIEWS.USERS_SETTINGS, VIEWS.API_SETTINGS, VIEWS.PERSONAL_SETTINGS], activateOnRouteNames: [VIEWS.USERS_SETTINGS, VIEWS.API_SETTINGS, VIEWS.PERSONAL_SETTINGS],
route: { to: defaultSettingsRoute },
}, },
{ {
id: 'help', id: 'help',
@ -296,40 +281,36 @@ export default defineComponent({
id: 'quickstart', id: 'quickstart',
icon: 'video', icon: 'video',
label: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'), label: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'),
type: 'link', link: {
properties: {
href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4', href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4',
newWindow: true, target: '_blank',
}, },
}, },
{ {
id: 'docs', id: 'docs',
icon: 'book', icon: 'book',
label: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'), label: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'),
type: 'link', link: {
properties: {
href: 'https://docs.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar', href: 'https://docs.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar',
newWindow: true, target: '_blank',
}, },
}, },
{ {
id: 'forum', id: 'forum',
icon: 'users', icon: 'users',
label: this.$locale.baseText('mainSidebar.helpMenuItems.forum'), label: this.$locale.baseText('mainSidebar.helpMenuItems.forum'),
type: 'link', link: {
properties: {
href: 'https://community.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar', href: 'https://community.n8n.io?utm_source=n8n_app&utm_medium=app_sidebar',
newWindow: true, target: '_blank',
}, },
}, },
{ {
id: 'examples', id: 'examples',
icon: 'graduation-cap', icon: 'graduation-cap',
label: this.$locale.baseText('mainSidebar.helpMenuItems.course'), label: this.$locale.baseText('mainSidebar.helpMenuItems.course'),
type: 'link', link: {
properties: {
href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4', href: 'https://www.youtube.com/watch?v=1MwSoB0gnM4',
newWindow: true, target: '_blank',
}, },
}, },
{ {
@ -421,46 +402,6 @@ export default defineComponent({
}, },
async handleSelect(key: string) { async handleSelect(key: string) {
switch (key) { 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': { case 'about': {
this.trackHelpItemClick('about'); this.trackHelpItemClick('about');
this.uiStore.openModal(ABOUT_MODAL_KEY); this.uiStore.openModal(ABOUT_MODAL_KEY);
@ -481,25 +422,18 @@ export default defineComponent({
break; 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() { findFirstAccessibleSettingsRoute() {
const settingsRoutes = this.$router const settingsRoutes = this.$router
.getRoutes() .getRoutes()
.find((route) => route.path === '/settings')! .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) { for (const route of settingsRoutes) {
if (this.canUserAccessRouteByName(route.toString())) { if (this.canUserAccessRouteByName(route.toString())) {
defaultSettingsRoute = route; defaultSettingsRoute = {
name: route.toString() as VIEWS,
};
break; break;
} }
} }

View file

@ -49,7 +49,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.usageAndPlan.title'), label: this.$locale.baseText('settings.usageAndPlan.title'),
position: 'top', position: 'top',
available: this.canAccessUsageAndPlan(), available: this.canAccessUsageAndPlan(),
activateOnRouteNames: [VIEWS.USAGE], route: { to: { name: VIEWS.USAGE } },
}, },
{ {
id: 'settings-personal', id: 'settings-personal',
@ -57,7 +57,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.personal'), label: this.$locale.baseText('settings.personal'),
position: 'top', position: 'top',
available: this.canAccessPersonalSettings(), available: this.canAccessPersonalSettings(),
activateOnRouteNames: [VIEWS.PERSONAL_SETTINGS], route: { to: { name: VIEWS.PERSONAL_SETTINGS } },
}, },
{ {
id: 'settings-users', id: 'settings-users',
@ -65,7 +65,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.users'), label: this.$locale.baseText('settings.users'),
position: 'top', position: 'top',
available: this.canAccessUsersSettings(), available: this.canAccessUsersSettings(),
activateOnRouteNames: [VIEWS.USERS_SETTINGS], route: { to: { name: VIEWS.USERS_SETTINGS } },
}, },
{ {
id: 'settings-api', id: 'settings-api',
@ -73,7 +73,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.n8napi'), label: this.$locale.baseText('settings.n8napi'),
position: 'top', position: 'top',
available: this.canAccessApiSettings(), available: this.canAccessApiSettings(),
activateOnRouteNames: [VIEWS.API_SETTINGS], route: { to: { name: VIEWS.API_SETTINGS } },
}, },
{ {
id: 'settings-external-secrets', id: 'settings-external-secrets',
@ -81,10 +81,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.externalSecrets.title'), label: this.$locale.baseText('settings.externalSecrets.title'),
position: 'top', position: 'top',
available: this.canAccessExternalSecrets(), available: this.canAccessExternalSecrets(),
activateOnRouteNames: [ route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } },
VIEWS.EXTERNAL_SECRETS_SETTINGS,
VIEWS.EXTERNAL_SECRETS_PROVIDER_SETTINGS,
],
}, },
{ {
id: 'settings-audit-logs', id: 'settings-audit-logs',
@ -92,7 +89,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.auditLogs.title'), label: this.$locale.baseText('settings.auditLogs.title'),
position: 'top', position: 'top',
available: this.canAccessAuditLogs(), available: this.canAccessAuditLogs(),
activateOnRouteNames: [VIEWS.AUDIT_LOGS], route: { to: { name: VIEWS.AUDIT_LOGS } },
}, },
{ {
id: 'settings-source-control', id: 'settings-source-control',
@ -100,7 +97,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.sourceControl.title'), label: this.$locale.baseText('settings.sourceControl.title'),
position: 'top', position: 'top',
available: this.canAccessSourceControl(), available: this.canAccessSourceControl(),
activateOnRouteNames: [VIEWS.SOURCE_CONTROL], route: { to: { name: VIEWS.SOURCE_CONTROL } },
}, },
{ {
id: 'settings-sso', id: 'settings-sso',
@ -108,7 +105,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.sso'), label: this.$locale.baseText('settings.sso'),
position: 'top', position: 'top',
available: this.canAccessSso(), available: this.canAccessSso(),
activateOnRouteNames: [VIEWS.SSO_SETTINGS], route: { to: { name: VIEWS.SSO_SETTINGS } },
}, },
{ {
id: 'settings-ldap', id: 'settings-ldap',
@ -116,7 +113,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.ldap'), label: this.$locale.baseText('settings.ldap'),
position: 'top', position: 'top',
available: this.canAccessLdapSettings(), available: this.canAccessLdapSettings(),
activateOnRouteNames: [VIEWS.LDAP_SETTINGS], route: { to: { name: VIEWS.LDAP_SETTINGS } },
}, },
{ {
id: 'settings-workersview', id: 'settings-workersview',
@ -126,7 +123,7 @@ export default defineComponent({
available: available:
this.settingsStore.isQueueModeEnabled && this.settingsStore.isQueueModeEnabled &&
hasPermission(['rbac'], { rbac: { scope: 'workersView:manage' } }), 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')) { if (item.uiLocations.includes('settings')) {
menuItems.push({ menuItems.push({
id: item.id, id: item.id,
icon: item.icon || 'question', icon: item.icon ?? 'question',
label: this.$locale.baseText(item.featureName as BaseTextKey), label: this.$locale.baseText(item.featureName as BaseTextKey),
position: 'top', position: 'top',
available: true, available: true,
@ -149,7 +146,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.log-streaming'), label: this.$locale.baseText('settings.log-streaming'),
position: 'top', position: 'top',
available: this.canAccessLogStreamingSettings(), available: this.canAccessLogStreamingSettings(),
activateOnRouteNames: [VIEWS.LOG_STREAMING_SETTINGS], route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } },
}); });
menuItems.push({ menuItems.push({
@ -158,7 +155,7 @@ export default defineComponent({
label: this.$locale.baseText('settings.communityNodes'), label: this.$locale.baseText('settings.communityNodes'),
position: 'top', position: 'top',
available: this.canAccessCommunityNodes(), available: this.canAccessCommunityNodes(),
activateOnRouteNames: [VIEWS.COMMUNITY_NODES], route: { to: { name: VIEWS.COMMUNITY_NODES } },
}); });
return menuItems; return menuItems;
@ -211,51 +208,10 @@ export default defineComponent({
}, },
async handleSelect(key: string) { async handleSelect(key: string) {
switch (key) { 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 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
case 'logging': case 'logging':
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {}); this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
break; 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: default:
break; break;
} }

View file

@ -16,6 +16,7 @@ vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({ useRoute: vi.fn().mockReturnValue({
name: VIEWS.WORKFLOW_EXECUTIONS, name: VIEWS.WORKFLOW_EXECUTIONS,
}), }),
RouterLink: vi.fn(),
})); }));
let pinia: ReturnType<typeof createTestingPinia>; let pinia: ReturnType<typeof createTestingPinia>;

View file

@ -9,6 +9,7 @@ vi.mock('vue-router', () => ({
path: '/workflows', path: '/workflows',
params: {}, params: {},
})), })),
RouterLink: vi.fn(),
})); }));
vi.mock('@/stores/rbac.store', () => ({ vi.mock('@/stores/rbac.store', () => ({

View file

@ -31,6 +31,7 @@ vi.mock('@/stores/ui.store', () => {
}); });
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({}), useRoute: () => ({}),
RouterLink: vi.fn(),
})); }));
const TestComponent = defineComponent({ const TestComponent = defineComponent({

View file

@ -7,7 +7,7 @@ import type {
RouteLocationRaw, RouteLocationRaw,
RouteLocationNormalized, RouteLocationNormalized,
} from 'vue-router'; } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory, isNavigationFailure } from 'vue-router';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useTemplatesStore } from '@/stores/templates.store'; import { useTemplatesStore } from '@/stores/templates.store';
@ -784,6 +784,7 @@ const router = createRouter({
}); });
router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => { router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => {
try {
/** /**
* Initialize application core * Initialize application core
* This step executes before first route is loaded and is required for permission checks * This step executes before first route is loaded and is required for permission checks
@ -827,9 +828,17 @@ router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next)
} }
return next(); return next();
} catch (failure) {
if (isNavigationFailure(failure)) {
console.log(failure);
} else {
console.error(failure);
}
}
}); });
router.afterEach((to, from) => { router.afterEach((to, from) => {
try {
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const uiStore = useUIStore(); const uiStore = useUIStore();
const templatesStore = useTemplatesStore(); const templatesStore = useTemplatesStore();
@ -851,6 +860,13 @@ router.afterEach((to, from) => {
templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages
} }
telemetry.page(to); telemetry.page(to);
} catch (failure) {
if (isNavigationFailure(failure)) {
console.log(failure);
} else {
console.error(failure);
}
}
}); });
export default router; export default router;

View file

@ -44,7 +44,6 @@ import type {
CloudUpdateLinkSourceType, CloudUpdateLinkSourceType,
CurlToJSONResponse, CurlToJSONResponse,
IFakeDoorLocation, IFakeDoorLocation,
IMenuItem,
INodeUi, INodeUi,
IOnboardingCallPrompt, IOnboardingCallPrompt,
IUser, IUser,
@ -176,7 +175,6 @@ export const useUIStore = defineStore(STORES.UI, {
nodeViewOffsetPosition: [0, 0], nodeViewOffsetPosition: [0, 0],
nodeViewMoveInProgress: false, nodeViewMoveInProgress: false,
selectedNodes: [], selectedNodes: [],
sidebarMenuItems: [],
nodeViewInitialized: false, nodeViewInitialized: false,
addFirstStepOnLoad: false, addFirstStepOnLoad: false,
executionSidebarAutoRefresh: true, executionSidebarAutoRefresh: true,
@ -528,10 +526,6 @@ export const useUIStore = defineStore(STORES.UI, {
resetSelectedNodes(): void { resetSelectedNodes(): void {
this.selectedNodes = []; this.selectedNodes = [];
}, },
addSidebarMenuItems(menuItems: IMenuItem[]) {
const updated = this.sidebarMenuItems.concat(menuItems);
this.sidebarMenuItems = updated;
},
setCurlCommand(payload: { name: string; command: string }): void { setCurlCommand(payload: { name: string; command: string }): void {
this.modals[payload.name] = { this.modals[payload.name] = {
...this.modals[payload.name], ...this.modals[payload.name],

View file

@ -14,6 +14,7 @@ vi.mock('vue-router', () => {
useRouter: () => ({ useRouter: () => ({
push, push,
}), }),
RouterLink: vi.fn(),
}; };
}); });

View file

@ -33,6 +33,7 @@ vi.mock('vue-router', () => {
replace, replace,
resolve, resolve,
}), }),
RouterLink: vi.fn(),
}; };
}); });

View file

@ -867,6 +867,9 @@ importers:
vue-boring-avatars: vue-boring-avatars:
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(vue@3.3.4) version: 1.3.0(vue@3.3.4)
vue-router:
specifier: ^4.2.2
version: 4.2.2(vue@3.3.4)
xss: xss:
specifier: ^1.0.14 specifier: ^1.0.14
version: 1.0.14 version: 1.0.14