feat(editor): Add navigation dropdown component (#11047)

This commit is contained in:
Raúl Gómez Morales 2024-10-03 15:03:09 +02:00 committed by GitHub
parent 6d716494a4
commit e081fd1f0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 314 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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