mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 13:27:31 -08:00
feat(editor): Add navigation dropdown component (#11047)
This commit is contained in:
parent
6d716494a4
commit
e081fd1f0b
|
@ -0,0 +1,63 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import type { StoryFn } from '@storybook/vue3';
|
||||
|
||||
import NavigationDropdown from './NavigationDropdown.vue';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/NavigationDropdown',
|
||||
component: NavigationDropdown,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const methods = {
|
||||
onSelect: action('select'),
|
||||
};
|
||||
|
||||
const template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
NavigationDropdown,
|
||||
},
|
||||
template: `
|
||||
<div style="height: 10vh; width: 200px">
|
||||
<n8n-navigation-dropdown v-bind="args" @select="onSelect">
|
||||
<button type="button">trigger</button>
|
||||
</n8n-navigation-dropdown>
|
||||
</div>
|
||||
`,
|
||||
methods,
|
||||
});
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'credentials',
|
||||
title: 'Credentials',
|
||||
submenu: [
|
||||
{
|
||||
id: 'credentials-0',
|
||||
title: 'Create',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'credentials-1',
|
||||
title: 'Credentials - 1',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
id: 'credentials-2',
|
||||
title: 'Credentials - 2',
|
||||
icon: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'variables',
|
||||
title: 'Variables',
|
||||
},
|
||||
];
|
||||
|
||||
export const primary = template.bind({});
|
||||
primary.args = {
|
||||
menu: menuItems,
|
||||
};
|
|
@ -0,0 +1,127 @@
|
|||
<script setup lang="ts">
|
||||
import { ElMenu, ElSubMenu, ElMenuItem, type MenuItemRegistered } from 'element-plus';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
|
||||
type BaseItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
icon?: string;
|
||||
route?: RouteLocationRaw;
|
||||
};
|
||||
|
||||
type Item = BaseItem & {
|
||||
submenu?: BaseItem[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
menu: Item[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
itemClick: [item: MenuItemRegistered];
|
||||
select: [id: Item['id']];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElMenu
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
:class="$style.dropdown"
|
||||
@select="emit('select', $event)"
|
||||
>
|
||||
<ElSubMenu
|
||||
index="-1"
|
||||
:class="$style.trigger"
|
||||
:popper-offset="-10"
|
||||
:popper-class="$style.submenu"
|
||||
>
|
||||
<template #title>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<template v-for="item in menu" :key="item.id">
|
||||
<template v-if="item.submenu">
|
||||
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu">
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template v-for="subitem in item.submenu" :key="subitem.id">
|
||||
<ConditionalRouterLink :to="subitem.route">
|
||||
<ElMenuItem
|
||||
data-test-id="navigation-submenu-item"
|
||||
:index="subitem.id"
|
||||
:disabled="subitem.disabled"
|
||||
@click="emit('itemClick', $event)"
|
||||
>
|
||||
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
||||
{{ subitem.title }}
|
||||
</ElMenuItem>
|
||||
</ConditionalRouterLink>
|
||||
</template>
|
||||
</ElSubMenu>
|
||||
</template>
|
||||
<ConditionalRouterLink v-else :to="item.route">
|
||||
<ElMenuItem
|
||||
:index="item.id"
|
||||
:disabled="item.disabled"
|
||||
data-test-id="navigation-menu-item"
|
||||
>
|
||||
{{ item.title }}
|
||||
</ElMenuItem>
|
||||
</ConditionalRouterLink>
|
||||
</template>
|
||||
</ElSubMenu>
|
||||
</ElMenu>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
:global(.el-menu).dropdown {
|
||||
border-bottom: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:global(.el-sub-menu).trigger {
|
||||
:global(.el-sub-menu__title) {
|
||||
height: auto;
|
||||
line-height: initial;
|
||||
border-bottom: 0 !important;
|
||||
:global(.el-sub-menu__icon-arrow) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.el-sub-menu__title:hover) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu {
|
||||
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
|
||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
|
||||
:global(.el-menu--horizontal .el-menu .el-menu-item.is-disabled) {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
:global(.el-sub-menu__icon-arrow svg) {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu__icon {
|
||||
margin-right: var(--spacing-2xs);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,120 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { configure, render, waitFor } from '@testing-library/vue';
|
||||
import { h } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import NavigationDropdown from '../NavigationDropdown.vue';
|
||||
|
||||
configure({ testIdAttribute: 'data-test-id' });
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: { template: '<h1>projects</h1>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('N8nNavigationDropdown', () => {
|
||||
beforeAll(async () => {
|
||||
await router.push('/');
|
||||
|
||||
await router.isReady();
|
||||
});
|
||||
it('default slot should trigger first level', async () => {
|
||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
||||
props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('test-trigger')).toBeVisible();
|
||||
expect(queryByTestId('navigation-menu-item')).not.toBeVisible();
|
||||
|
||||
await userEvent.hover(getByTestId('test-trigger'));
|
||||
await waitFor(() => expect(queryByTestId('navigation-menu-item')).toBeVisible());
|
||||
});
|
||||
|
||||
it('redirect to route', async () => {
|
||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
||||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('test-trigger')).toBeVisible();
|
||||
expect(queryByTestId('navigation-submenu')).not.toBeVisible();
|
||||
|
||||
await userEvent.hover(getByTestId('test-trigger'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('navigation-submenu')).toBeVisible());
|
||||
|
||||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('projects');
|
||||
});
|
||||
|
||||
it('should render icons in submenu when provided', () => {
|
||||
const { getByTestId } = render(NavigationDropdown, {
|
||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
||||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('navigation-submenu-item').querySelector('.submenu__icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should propagate events', async () => {
|
||||
const { getByTestId, emitted } = render(NavigationDropdown, {
|
||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
||||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||
|
||||
expect(emitted('itemClick')).toStrictEqual([
|
||||
[{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }],
|
||||
]);
|
||||
expect(emitted('select')).toStrictEqual([['bbb']]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import N8nNavigationDropdown from './NavigationDropdown.vue';
|
||||
|
||||
export default N8nNavigationDropdown;
|
|
@ -28,6 +28,7 @@ export { default as N8nLoading } from './N8nLoading';
|
|||
export { default as N8nMarkdown } from './N8nMarkdown';
|
||||
export { default as N8nMenu } from './N8nMenu';
|
||||
export { default as N8nMenuItem } from './N8nMenuItem';
|
||||
export { default as N8nNavigationDropdown } from './N8nNavigationDropdown';
|
||||
export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode';
|
||||
export { default as N8nNodeIcon } from './N8nNodeIcon';
|
||||
export { default as N8nNotice } from './N8nNotice';
|
||||
|
|
Loading…
Reference in a new issue