mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
fix(editor): Use web native <a> element in nav menus (#8385)
This commit is contained in:
parent
6fcf5ddcdd
commit
e606e841ee
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>"`;
|
|
@ -0,0 +1,3 @@
|
|||
import CondtionalRouterLink from './CondtionalRouterLink.vue';
|
||||
|
||||
export default CondtionalRouterLink;
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
}
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
@ -1270,7 +1270,6 @@ export interface UIState {
|
|||
nodeViewOffsetPosition: XYPosition;
|
||||
nodeViewMoveInProgress: boolean;
|
||||
selectedNodes: INodeUi[];
|
||||
sidebarMenuItems: IMenuItem[];
|
||||
nodeViewInitialized: boolean;
|
||||
addFirstStepOnLoad: boolean;
|
||||
executionSidebarAutoRefresh: boolean;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ vi.mock('vue-router', () => ({
|
|||
useRoute: vi.fn().mockReturnValue({
|
||||
name: VIEWS.WORKFLOW_EXECUTIONS,
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
|
|
@ -9,6 +9,7 @@ vi.mock('vue-router', () => ({
|
|||
path: '/workflows',
|
||||
params: {},
|
||||
})),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/rbac.store', () => ({
|
||||
|
|
|
@ -31,6 +31,7 @@ vi.mock('@/stores/ui.store', () => {
|
|||
});
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -14,6 +14,7 @@ vi.mock('vue-router', () => {
|
|||
useRouter: () => ({
|
||||
push,
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ vi.mock('vue-router', () => {
|
|||
replace,
|
||||
resolve,
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue