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",
"vue": "^3.3.4",
"vue-boring-avatars": "^1.3.0",
"vue-router": "^4.2.2",
"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',
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',
},
},
],

View file

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

View file

@ -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',
},
},
],

View file

@ -40,37 +40,39 @@
:disabled="!compact"
:show-after="tooltipDelay"
>
<ElMenuItem
:id="item.id"
:class="{
[$style.menuItem]: true,
[$style.item]: true,
[$style.disableActiveStyle]: !isItemActive(item),
[$style.active]: isItemActive(item),
[$style.compact]: compact,
}"
data-test-id="menu-item"
:index="item.id"
@click="handleSelect(item)"
>
<N8nIcon
v-if="item.icon"
:class="$style.icon"
:icon="item.icon"
:size="item.customIconSize || 'large'"
/>
<span :class="$style.label">{{ item.label }}</span>
<N8nTooltip
v-if="item.secondaryIcon"
:class="$style.secondaryIcon"
:placement="item.secondaryIcon?.tooltip?.placement || 'right'"
:content="item.secondaryIcon?.tooltip?.content"
:disabled="compact || !item.secondaryIcon?.tooltip?.content"
:show-after="tooltipDelay"
<ConditionalRouterLink v-bind="item.route ?? item.link">
<ElMenuItem
:id="item.id"
:class="{
[$style.menuItem]: true,
[$style.item]: true,
[$style.disableActiveStyle]: !isItemActive(item),
[$style.active]: isItemActive(item),
[$style.compact]: compact,
}"
data-test-id="menu-item"
:index="item.id"
@click="handleSelect(item)"
>
<N8nIcon :icon="item.secondaryIcon.name" :size="item.secondaryIcon.size || 'small'" />
</N8nTooltip>
</ElMenuItem>
<N8nIcon
v-if="item.icon"
:class="$style.icon"
:icon="item.icon"
:size="item.customIconSize || 'large'"
/>
<span :class="$style.label">{{ item.label }}</span>
<N8nTooltip
v-if="item.secondaryIcon"
:class="$style.secondaryIcon"
:placement="item.secondaryIcon?.tooltip?.placement || 'right'"
:content="item.secondaryIcon?.tooltip?.content"
:disabled="compact || !item.secondaryIcon?.tooltip?.content"
:show-after="tooltipDelay"
>
<N8nIcon :icon="item.secondaryIcon.name" :size="item.secondaryIcon.size || 'small'" />
</N8nTooltip>
</ElMenuItem>
</ConditionalRouterLink>
</N8nTooltip>
</div>
</template>
@ -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;
}

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 { 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<ElTooltipProps>;
};
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'];
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<unknown>;
await middlewareFn(to, from, middlewareNext, middlewareOptions);
const middlewareOptions = routeMiddlewareOptions[middlewareName];
const middlewareFn = middleware[middlewareName] as RouterMiddleware<unknown>;
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;

View file

@ -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],

View file

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

View file

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

View file

@ -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