Commit 0ebb1a5d authored by Paul Slaughter's avatar Paul Slaughter Committed by Jose Ivan Vargas

Add top_nav_menu_sections component

parent 9641dac4
......@@ -2,12 +2,12 @@
import FrequentItemsApp from '~/frequent_items/components/app.vue';
import eventHub from '~/frequent_items/event_hub';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import TopNavMenuItem from './top_nav_menu_item.vue';
import TopNavMenuSections from './top_nav_menu_sections.vue';
export default {
components: {
FrequentItemsApp,
TopNavMenuItem,
TopNavMenuSections,
VuexModuleProvider,
},
inheritAttrs: false,
......@@ -32,11 +32,11 @@ export default {
},
},
computed: {
linkGroups() {
menuSections() {
return [
{ key: 'primary', links: this.linksPrimary },
{ key: 'secondary', links: this.linksSecondary },
].filter((x) => x.links?.length);
{ id: 'primary', menuItems: this.linksPrimary },
{ id: 'secondary', menuItems: this.linksSecondary },
].filter((x) => x.menuItems?.length);
},
},
mounted() {
......@@ -57,19 +57,6 @@ export default {
</vuex-module-provider>
</div>
</div>
<div
v-for="({ key, links }, groupIndex) in linkGroups"
:key="key"
:class="{ 'gl-mt-3': groupIndex !== 0 }"
class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
data-testid="menu-item-group"
>
<top-nav-menu-item
v-for="(link, linkIndex) in links"
:key="link.title"
:menu-item="link"
:class="{ 'gl-mt-1': linkIndex !== 0 }"
/>
</div>
<top-nav-menu-sections class="gl-mt-auto" :sections="menuSections" with-top-border />
</div>
</template>
<script>
import { cloneDeep } from 'lodash';
import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import TopNavContainerView from './top_nav_container_view.vue';
import TopNavMenuItem from './top_nav_menu_item.vue';
const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active';
const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
import TopNavMenuSections from './top_nav_menu_sections.vue';
export default {
components: {
KeepAliveSlots,
TopNavContainerView,
TopNavMenuItem,
TopNavMenuSections,
},
props: {
primary: {
......@@ -31,29 +29,25 @@ export default {
},
},
data() {
// It's expected that primary & secondary never change, so these are treated as "init" props.
// We need to clone so that we can mutate the data without mutating the props
const menuSections = [
{ id: 'primary', menuItems: cloneDeep(this.primary) },
{ id: 'secondary', menuItems: cloneDeep(this.secondary) },
].filter((x) => x.menuItems?.length);
return {
activeId: '',
menuSections,
};
},
computed: {
menuItemGroups() {
return [
{ key: 'primary', items: this.primary, classes: '' },
{
key: 'secondary',
items: this.secondary,
classes: SECONDARY_GROUP_CLASS,
},
].filter((x) => x.items?.length);
},
allMenuItems() {
return this.menuItemGroups.flatMap((x) => x.items);
},
activeMenuItem() {
return this.allMenuItems.find((x) => x.id === this.activeId);
return this.menuSections.flatMap((x) => x.menuItems);
},
activeView() {
return this.activeMenuItem?.view;
const active = this.allMenuItems.find((x) => x.active);
return active?.view;
},
menuClass() {
if (!this.activeView) {
......@@ -63,61 +57,26 @@ export default {
return '';
},
},
created() {
// Initialize activeId based on initialization prop
this.activeId = this.allMenuItems.find((x) => x.active)?.id;
},
methods: {
onClick({ id, href }) {
// If we're a link, let's just do the default behavior so the view won't change
if (href) {
return;
}
this.activeId = id;
},
menuItemClasses(menuItem) {
if (menuItem.id === this.activeId) {
return ACTIVE_CLASS;
}
return '';
onMenuItemClick({ id }) {
this.allMenuItems.forEach((menuItem) => {
this.$set(menuItem, 'active', id === menuItem.id);
});
},
},
FREQUENT_ITEMS_PROJECTS,
FREQUENT_ITEMS_GROUPS,
// expose for unit tests
ACTIVE_CLASS,
SECONDARY_GROUP_CLASS,
};
</script>
<template>
<div class="gl-display-flex gl-align-items-stretch">
<div
class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10"
class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-py-3 gl-px-5"
:class="menuClass"
data-testid="menu-sidebar"
>
<div
class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column"
>
<div
v-for="group in menuItemGroups"
:key="group.key"
:class="group.classes"
data-testid="menu-item-group"
>
<top-nav-menu-item
v-for="(menu, index) in group.items"
:key="menu.id"
data-testid="menu-item"
:class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]"
:menu-item="menu"
@click="onClick(menu)"
/>
</div>
</div>
<top-nav-menu-sections :sections="menuSections" @menu-item-click="onMenuItemClick" />
</div>
<keep-alive-slots
v-show="activeView"
......
......@@ -4,6 +4,8 @@ import { kebabCase, mapKeys } from 'lodash';
const getDataKey = (key) => `data-${kebabCase(key)}`;
const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active';
export default {
components: {
GlButton,
......@@ -20,6 +22,7 @@ export default {
return mapKeys(this.menuItem.data || {}, (value, key) => getDataKey(key));
},
},
ACTIVE_CLASS,
};
</script>
......@@ -28,7 +31,7 @@ export default {
category="tertiary"
:href="menuItem.href"
class="top-nav-menu-item gl-display-block"
:class="menuItem.css_class"
:class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]"
v-bind="dataAttrs"
v-on="$listeners"
>
......
<script>
import TopNavMenuItem from './top_nav_menu_item.vue';
const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
export default {
components: {
TopNavMenuItem,
},
props: {
sections: {
type: Array,
required: true,
},
withTopBorder: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
onClick(menuItem) {
// If we're a link, let's just do the default behavior so the view won't change
if (menuItem.href) {
return;
}
this.$emit('menu-item-click', menuItem);
},
getMenuSectionClasses(index) {
// This is a method instead of a computed so we don't have to incur the cost of
// creating a whole new array/object.
return {
[BORDER_CLASSES]: this.withTopBorder || index > 0,
'gl-mt-3': index > 0,
};
},
},
// Expose for unit tests
BORDER_CLASSES,
};
</script>
<template>
<div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column">
<div
v-for="({ id, menuItems }, sectionIndex) in sections"
:key="id"
:class="getMenuSectionClasses(sectionIndex)"
data-testid="menu-section"
>
<top-nav-menu-item
v-for="(menuItem, menuItemIndex) in menuItems"
:key="menuItem.id"
:menu-item="menuItem"
data-testid="menu-item"
:class="{ 'gl-mt-1': menuItemIndex > 0 }"
@click="onClick(menuItem)"
/>
</div>
</div>
</template>
......@@ -4,7 +4,7 @@ import FrequentItemsApp from '~/frequent_items/components/app.vue';
import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants';
import eventHub from '~/frequent_items/event_hub';
import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import { TEST_NAV_DATA } from '../mock_data';
......@@ -34,11 +34,7 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
});
};
const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem);
const findMenuItemsModel = (parent = wrapper) =>
findMenuItems(parent).wrappers.map((x) => x.props());
const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel);
const findMenuSections = () => wrapper.findComponent(TopNavMenuSections);
const findFrequentItemsApp = () => {
const parent = wrapper.findComponent(VuexModuleProvider);
......@@ -89,23 +85,16 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
});
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })),
]);
});
it('renders menu sections', () => {
const sections = [
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
{ id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
];
it('only the first group does not have margin top', () => {
expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]);
expect(findMenuSections().props()).toEqual({
sections,
withTopBorder: true,
});
it('only the first menu item does not have margin top', () => {
const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) =>
x.classes('gl-mt-1'),
);
expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]);
});
});
......@@ -117,8 +106,8 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
});
it('renders one menu item group', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
expect(findMenuSections().props('sections')).toEqual([
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
]);
});
});
......
import { shallowMount } from '@vue/test-utils';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import { TEST_NAV_DATA } from '../mock_data';
const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' ');
describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavDropdownMenu, {
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(TopNavDropdownMenu, {
propsData: {
primary: TEST_NAV_DATA.primary,
secondary: TEST_NAV_DATA.secondary,
views: TEST_NAV_DATA.views,
...props,
},
stubs: {
// Stub the keep-alive-slots so we don't render frequent items which uses a store
KeepAliveSlots: true,
},
});
};
const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]');
const findMenuItemsModel = (parent = wrapper) =>
findMenuItems(parent).wrappers.map((x) => ({
menuItem: x.props('menuItem'),
isActive: x.classes('active'),
}));
const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
const findMenuItemGroupsModel = () =>
findMenuItemGroups().wrappers.map((x) => ({
classes: x.classes(),
items: findMenuItemsModel(x),
}));
const findMenuItems = () => wrapper.findAllComponents(TopNavMenuItem);
const findMenuSections = () => wrapper.find(TopNavMenuSections);
const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]');
const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots);
const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full');
const createItemsGroupModelExpectation = ({
primary = TEST_NAV_DATA.primary,
secondary = TEST_NAV_DATA.secondary,
activeIndex = -1,
} = {}) => [
{
classes: [],
items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })),
},
{
classes: SECONDARY_GROUP_CLASSES,
items: secondary.map((menuItem) => ({ isActive: false, menuItem })),
},
];
const withActiveIndex = (menuItems, activeIndex) =>
menuItems.map((x, idx) => ({
...x,
active: idx === activeIndex,
}));
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation());
it('renders menu sections', () => {
expect(findMenuSections().props()).toEqual({
sections: [
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
{ id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
],
withTopBorder: false,
});
});
it('has full width menu sidebar', () => {
......@@ -74,36 +69,25 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
expect(subview.isVisible()).toBe(false);
expect(subview.props()).toEqual({ slotKey: '' });
});
it('the first menu item in a group does not render margin top', () => {
const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) =>
x.classes('gl-mt-1'),
);
expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]);
});
});
describe('with pre-initialized active view', () => {
const primaryWithActive = [
TEST_NAV_DATA.primary[0],
beforeEach(() => {
// We opt for a small integration test, to make sure the event is handled correctly
// as it would in prod.
createComponent(
{
...TEST_NAV_DATA.primary[1],
active: true,
primary: withActiveIndex(TEST_NAV_DATA.primary, 1),
},
...TEST_NAV_DATA.primary.slice(2),
];
beforeEach(() => {
createComponent({
primary: primaryWithActive,
});
mount,
);
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }),
);
it('renders menu sections', () => {
expect(findMenuSections().props('sections')).toStrictEqual([
{ id: 'primary', menuItems: withActiveIndex(TEST_NAV_DATA.primary, 1) },
{ id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
]);
});
it('does not have full width menu sidebar', () => {
......@@ -114,11 +98,11 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
const subview = findMenuSubview();
expect(subview.isVisible()).toBe(true);
expect(subview.props('slotKey')).toBe(primaryWithActive[1].view);
expect(subview.props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view);
});
it('does not change view if non-view menu item is clicked', async () => {
const secondaryLink = findMenuItems().at(primaryWithActive.length);
const secondaryLink = findMenuItems().at(TEST_NAV_DATA.primary.length);
// Ensure this doesn't have a view
expect(secondaryLink.props('menuItem').view).toBeUndefined();
......@@ -127,10 +111,10 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
await nextTick();
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view);
expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view);
});
describe('when other view menu item is clicked', () => {
describe('when menu item is clicked', () => {
let primaryLink;
beforeEach(async () => {
......@@ -144,13 +128,20 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
});
it('changes active view', () => {
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view);
expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[0].view);
});
it('changes active status on menu item', () => {
expect(findMenuItemGroupsModel()).toStrictEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }),
);
expect(findMenuSections().props('sections')).toStrictEqual([
{
id: 'primary',
menuItems: withActiveIndex(TEST_NAV_DATA.primary, 0),
},
{
id: 'secondary',
menuItems: withActiveIndex(TEST_NAV_DATA.secondary, -1),
},
]);
});
});
});
......
......@@ -7,7 +7,6 @@ const TEST_MENU_ITEM = {
icon: 'search',
href: '/pretty/good/burger',
view: 'burger-view',
css_class: 'test-super-crazy test-class',
data: { qa_selector: 'not-a-real-selector', method: 'post', testFoo: 'test' },
};
......@@ -49,12 +48,6 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
expect(button.text()).toBe(TEST_MENU_ITEM.title);
});
it('renders button classes', () => {
const button = findButton();
expect(button.classes()).toEqual(expect.arrayContaining(TEST_MENU_ITEM.css_class.split(' ')));
});
it('renders button data attributes', () => {
const button = findButton();
......@@ -89,4 +82,29 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
expect(findButtonIcons()).toEqual(expectedIcons);
});
});
describe.each`
desc | active | cssClass | expectedClasses
${'default'} | ${false} | ${''} | ${[]}
${'with css class'} | ${false} | ${'test-css-class testing-123'} | ${['test-css-class', 'testing-123']}
${'with css class & active'} | ${true} | ${'test-css-class'} | ${['test-css-class', ...TopNavMenuItem.ACTIVE_CLASS.split(' ')]}
`('$desc', ({ active, cssClass, expectedClasses }) => {
beforeEach(() => {
createComponent({
menuItem: {
...TEST_MENU_ITEM,
active,
css_class: cssClass,
},
});
});
it('renders expected classes', () => {
expect(wrapper.classes()).toStrictEqual([
'top-nav-menu-item',
'gl-display-block',
...expectedClasses,
]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
const TEST_SECTIONS = [
{
id: 'primary',
menuItems: [{ id: 'test', href: '/test/href' }, { id: 'foo' }, { id: 'bar' }],
},
{
id: 'secondary',
menuItems: [{ id: 'lorem' }, { id: 'ipsum' }],
},
];
describe('~/nav/components/top_nav_menu_sections.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavMenuSections, {
propsData: {
sections: TEST_SECTIONS,
...props,
},
});
};
const findMenuItemModels = (parent) =>
parent.findAll('[data-testid="menu-item"]').wrappers.map((x) => ({
menuItem: x.props('menuItem'),
classes: x.classes(),
}));
const findSectionModels = () =>
wrapper.findAll('[data-testid="menu-section"]').wrappers.map((x) => ({
classes: x.classes(),
menuItems: findMenuItemModels(x),
}));
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders sections with menu items', () => {
expect(findSectionModels()).toEqual([
{
classes: [],
menuItems: [
{
menuItem: TEST_SECTIONS[0].menuItems[0],
classes: [],
},
...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({
menuItem,
classes: ['gl-mt-1'],
})),
],
},
{
classes: [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'],
menuItems: [
{
menuItem: TEST_SECTIONS[1].menuItems[0],
classes: [],
},
...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({
menuItem,
classes: ['gl-mt-1'],
})),
],
},
]);
});
it('when clicked menu item with href, does nothing', () => {
const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(0);
menuItem.vm.$emit('click');
expect(wrapper.emitted()).toEqual({});
});
it('when clicked menu item without href, emits "menu-item-click"', () => {
const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(1);
menuItem.vm.$emit('click');
expect(wrapper.emitted('menu-item-click')).toEqual([[TEST_SECTIONS[0].menuItems[1]]]);
});
});
describe('with withTopBorder=true', () => {
beforeEach(() => {
createComponent({ withTopBorder: true });
});
it('renders border classes for top section', () => {
expect(findSectionModels().map((x) => x.classes)).toEqual([
[...TopNavMenuSections.BORDER_CLASSES.split(' ')],
[...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'],
]);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment