Commit 6e953772 authored by Paul Slaughter's avatar Paul Slaughter Committed by Jose Ivan Vargas

Frontend for top nav menu redesign

parent d02bf43a
......@@ -37,15 +37,16 @@ export const TRANSLATION_KEYS = {
},
};
export const FREQUENT_ITEMS_DROPDOWNS = [
{
namespace: 'projects',
key: 'project',
vuexModule: 'frequentProjects',
},
{
namespace: 'groups',
key: 'group',
vuexModule: 'frequentGroups',
},
];
export const FREQUENT_ITEMS_PROJECTS = {
namespace: 'projects',
key: 'project',
vuexModule: 'frequentProjects',
};
export const FREQUENT_ITEMS_GROUPS = {
namespace: 'groups',
key: 'group',
vuexModule: 'frequentGroups',
};
export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS];
......@@ -13,14 +13,16 @@ export const createFrequentItemsModule = (initState = {}) => ({
state: state(initState),
});
export const createStoreOptions = () => ({
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
});
export const createStore = () => {
return new Vuex.Store({
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
});
return new Vuex.Store(createStoreOptions());
};
......@@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import 'ee_else_ce/main_ee';
......@@ -80,6 +81,7 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
initTopNav();
initBreadcrumbs();
initTodoToggle();
initLogoAnimation();
......
<script>
import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
const TOOLTIP = s__('TopNav|Switch to...');
export default {
components: {
GlNav,
GlNavItemDropdown,
GlDropdownForm,
GlTooltip,
TopNavDropdownMenu,
},
props: {
navData: {
type: Object,
required: true,
},
},
methods: {
findTooltipTarget() {
// ### Why use a target function instead of `v-gl-tooltip`?
// To get the tooltip to align correctly, we need it to target the actual
// toggle button which we don't directly render.
return this.$el.querySelector('.js-top-nav-dropdown-toggle');
},
},
TOOLTIP,
};
</script>
<template>
<gl-nav class="navbar-sub-nav">
<gl-nav-item-dropdown
:text="navData.activeTitle"
icon="dot-grid"
menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!"
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
no-flip
>
<gl-dropdown-form>
<top-nav-dropdown-menu
:primary="navData.primary"
:secondary="navData.secondary"
:views="navData.views"
/>
</gl-dropdown-form>
</gl-nav-item-dropdown>
<gl-tooltip
boundary="window"
:boundary-padding="0"
:target="findTooltipTarget"
placement="right"
:title="$options.TOOLTIP"
/>
</gl-nav>
</template>
<script>
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';
export default {
components: {
FrequentItemsApp,
TopNavMenuItem,
VuexModuleProvider,
},
props: {
frequentItemsVuexModule: {
type: String,
required: true,
},
frequentItemsDropdownType: {
type: String,
required: true,
},
linksPrimary: {
type: Array,
required: false,
default: () => [],
},
linksSecondary: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
linkGroups() {
return [
{ key: 'primary', links: this.linksPrimary },
{ key: 'secondary', links: this.linksSecondary },
].filter((x) => x.links?.length);
},
},
mounted() {
// For historic reasons, the frequent-items-app component requires this too start up.
this.$nextTick(() => {
eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`);
});
},
};
</script>
<template>
<div class="top-nav-container-view gl-display-flex gl-flex-direction-column">
<div class="frequent-items-dropdown-container gl-w-auto">
<div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
<vuex-module-provider :vuex-module="frequentItemsVuexModule">
<frequent-items-app v-bind="$attrs" />
</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>
</div>
</template>
<script>
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';
export default {
components: {
KeepAliveSlots,
TopNavContainerView,
TopNavMenuItem,
},
props: {
primary: {
type: Array,
required: false,
default: () => [],
},
secondary: {
type: Array,
required: false,
default: () => [],
},
views: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
activeId: '',
};
},
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);
},
activeView() {
return this.activeMenuItem?.view;
},
menuClass() {
if (!this.activeView) {
return 'gl-w-full';
}
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 '';
},
},
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="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>
</div>
<keep-alive-slots
v-show="activeView"
:slot-key="activeView"
class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5"
data-testid="menu-subview"
>
<template #projects>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule"
v-bind="views.projects"
/>
</template>
<template #groups>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule"
v-bind="views.groups"
/>
</template>
</keep-alive-slots>
</div>
</template>
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
},
props: {
menuItem: {
type: Object,
required: true,
},
},
};
</script>
<template>
<gl-button
category="tertiary"
:href="menuItem.href"
class="top-nav-menu-item gl-display-block"
v-on="$listeners"
>
<span class="gl-display-flex">
<gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" />
{{ menuItem.title }}
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
</span>
</gl-button>
</template>
export const initTopNav = async () => {
const el = document.getElementById('js-top-nav');
if (!el) {
return;
}
// With combined_menu feature flag, there's a benefit to splitting up the import
const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount');
mountTopNav(el);
};
import Vue from 'vue';
import Vuex from 'vuex';
import App from './components/top_nav_app.vue';
import { createStore } from './stores';
Vue.use(Vuex);
export const mountTopNav = (el) => {
const viewModel = JSON.parse(el.dataset.viewModel);
const store = createStore();
return new Vue({
el,
store,
render(h) {
return h(App, {
props: {
navData: viewModel,
},
});
},
});
};
import Vuex from 'vuex';
import { createStoreOptions } from '~/frequent_items/store';
export const createStore = () => new Vuex.Store(createStoreOptions());
......@@ -839,8 +839,52 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.frequent-items-dropdown-container {
display: flex;
flex-direction: row;
width: 500px;
height: 354px;
height: $grid-size * 40;
&.with-deprecated-styles {
width: 500px;
height: 354px;
.section-header,
.frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
.search-icon {
position: absolute;
top: 13px;
right: 25px;
color: $gray-300;
}
}
@include media-breakpoint-down(xs) {
flex-direction: column;
width: 100%;
height: auto;
flex: 1;
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
width: 100%;
}
.frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
.frequent-items-list-container {
width: auto;
height: auto;
padding-bottom: 0;
}
}
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
......@@ -861,26 +905,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: 70%;
}
@include media-breakpoint-down(xs) {
flex-direction: column;
width: 100%;
height: auto;
flex: 1;
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
width: 100%;
}
.frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
.section-header,
.frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
......@@ -898,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
.search-icon {
position: absolute;
top: 13px;
right: 25px;
color: $gray-300;
}
}
.section-header {
font-weight: 700;
margin-top: 8px;
}
@include media-breakpoint-down(xs) {
.frequent-items-list-container {
width: auto;
height: auto;
padding-bottom: 0;
}
}
}
.frequent-items-list-item-container {
.frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
float: left;
flex-shrink: 0;
}
.frequent-items-item-metadata-container {
......
$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
.navbar-gitlab {
padding: 0 16px;
z-index: $header-zindex;
......@@ -254,6 +256,7 @@
}
}
.top-nav-toggle,
> button {
background: transparent;
border: 0;
......@@ -629,3 +632,36 @@
}
}
}
.top-nav-container-view {
.gl-new-dropdown & .gl-search-box-by-type {
@include gl-m-0;
}
.frequent-items-list-item-container > a:hover {
background-color: $top-nav-hover-bg;
}
}
.top-nav-toggle {
.dropdown-icon {
@include gl-mr-3;
}
.dropdown-chevron {
top: 0;
}
}
.top-nav-menu-item {
color: var(--indigo-900, $theme-indigo-900) !important;
&.active,
&:hover {
background-color: $top-nav-hover-bg;
}
.gl-icon {
color: inherit !important;
}
}
......@@ -283,6 +283,8 @@ $indigo-700: #4b4ba3;
$indigo-800: #393982;
$indigo-900: #292961;
$indigo-950: #1a1a40;
// To do this variant right for darkmode, we need to create a variable for it.
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$theme-blue-50: #f4f8fc;
$theme-blue-100: #e6edf5;
......
......@@ -70,6 +70,7 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0;
$indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$gray-lightest: #222;
$gray-light: $gray-50;
......@@ -160,6 +161,7 @@ body.gl-dark {
--indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950};
--indigo-900-alpha-008: #{$indigo-900-alpha-008};
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
......
......@@ -189,3 +189,21 @@ $gl-line-height-42: px-to-rem(42px);
.gl-line-height-42 {
line-height: $gl-line-height-42;
}
.gl-w-grid-size-30 {
width: $grid-size * 30;
}
.gl-w-grid-size-40 {
width: $grid-size * 40;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
.gl-max-w-none\! {
max-width: none !important;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
.gl-max-h-none\! {
max-height: none !important;
}
......@@ -20,7 +20,7 @@
= _('Next')
- if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
= render "layouts/nav/combined_menu"
= render "layouts/nav/top_nav"
- else
- if current_user
= render "layouts/nav/dashboard"
......
%button{ type: 'button', data: { toggle: "dropdown" } }
= sprite_icon('ellipsis_v')
= _('Projects')
- view_model = top_nav_view_model(project: @project, group: @group)
%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } }
%li
%a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } }
= sprite_icon('dot-grid', css_class: "dropdown-icon")
= view_model[:activeTitle]
= sprite_icon('chevron-down')
......@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted?
.frequent-items-dropdown-container
.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/groups#index') do
......
......@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
.frequent-items-dropdown-container
.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
......
......@@ -34070,6 +34070,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr ""
msgid "TopNav|Switch to..."
msgstr ""
msgid "Topics (optional)"
msgstr ""
......
......@@ -20,8 +20,6 @@ RSpec.describe 'Admin mode' do
context 'when not in admin mode' do
it 'has no leave admin mode button' do
pending_on_combined_menu_flag
visit new_admin_session_path
page.within('.navbar-sub-nav') do
......@@ -180,8 +178,6 @@ RSpec.describe 'Admin mode' do
end
it 'shows no admin mode buttons in navbar' do
pending_on_combined_menu_flag
visit admin_root_path
page.within('.navbar-sub-nav') do
......
import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import TopNavApp from '~/nav/components/top_nav_app.vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
import { TEST_NAV_DATA } from '../mock_data';
describe('~/nav/components/top_nav_app.vue', () => {
let wrapper;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TopNavApp, {
propsData: {
navData: TEST_NAV_DATA,
},
});
};
const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown);
const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
const findTooltip = () => wrapper.findComponent(GlTooltip);
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders nav item dropdown', () => {
expect(findNavItemDropdown().attributes('href')).toBeUndefined();
expect(findNavItemDropdown().attributes()).toMatchObject({
icon: 'dot-grid',
text: TEST_NAV_DATA.activeTitle,
'no-flip': '',
});
});
it('renders top nav dropdown menu', () => {
expect(findMenu().props()).toStrictEqual({
primary: TEST_NAV_DATA.primary,
secondary: TEST_NAV_DATA.secondary,
views: TEST_NAV_DATA.views,
});
});
it('renders tooltip', () => {
expect(findTooltip().attributes()).toMatchObject({
'boundary-padding': '0',
placement: 'right',
title: TopNavApp.TOOLTIP,
});
});
});
describe('when full mounted', () => {
beforeEach(() => {
createComponent(mount);
});
it('has dropdown toggle as tooltip target', () => {
const targetFn = findTooltip().props('target');
expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
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 VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import { TEST_NAV_DATA } from '../mock_data';
const DEFAULT_PROPS = {
frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace,
frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
linksPrimary: TEST_NAV_DATA.primary,
linksSecondary: TEST_NAV_DATA.secondary,
};
const TEST_OTHER_PROPS = {
namespace: 'projects',
currentUserName: '',
currentItem: {},
};
describe('~/nav/components/top_nav_container_view.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavContainerView, {
propsData: {
...DEFAULT_PROPS,
...TEST_OTHER_PROPS,
...props,
},
});
};
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 findFrequentItemsApp = () => {
const parent = wrapper.findComponent(VuexModuleProvider);
return {
vuexModule: parent.props('vuexModule'),
props: parent.findComponent(FrequentItemsApp).props(),
};
};
afterEach(() => {
wrapper.destroy();
});
it.each(['projects', 'groups'])(
'emits frequent items event to event hub (%s)',
async (frequentItemsDropdownType) => {
const listener = jest.fn();
eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener);
createComponent({ frequentItemsDropdownType });
expect(listener).not.toHaveBeenCalled();
await nextTick();
expect(listener).toHaveBeenCalled();
},
);
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders frequent items app', () => {
expect(findFrequentItemsApp()).toEqual({
vuexModule: DEFAULT_PROPS.frequentItemsVuexModule,
props: TEST_OTHER_PROPS,
});
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })),
]);
});
it('only the first group does not have margin top', () => {
expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, 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)]);
});
});
describe('without secondary links', () => {
beforeEach(() => {
createComponent({
linksSecondary: [],
});
});
it('renders one menu item group', () => {
expect(findMenuItemGroupsModel()).toEqual([
TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.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, {
propsData: {
primary: TEST_NAV_DATA.primary,
secondary: TEST_NAV_DATA.secondary,
views: TEST_NAV_DATA.views,
...props,
},
});
};
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 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 })),
},
];
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation());
});
it('has full width menu sidebar', () => {
expect(hasFullWidthMenuSidebar()).toBe(true);
});
it('renders hidden subview with no slot key', () => {
const subview = findMenuSubview();
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],
{
...TEST_NAV_DATA.primary[1],
active: true,
},
...TEST_NAV_DATA.primary.slice(2),
];
beforeEach(() => {
createComponent({
primary: primaryWithActive,
});
});
it('renders menu item groups', () => {
expect(findMenuItemGroupsModel()).toEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }),
);
});
it('does not have full width menu sidebar', () => {
expect(hasFullWidthMenuSidebar()).toBe(false);
});
it('renders visible subview with slot key', () => {
const subview = findMenuSubview();
expect(subview.isVisible()).toBe(true);
expect(subview.props('slotKey')).toBe(primaryWithActive[1].view);
});
it('does not change view if non-view menu item is clicked', async () => {
const secondaryLink = findMenuItems().at(primaryWithActive.length);
// Ensure this doesn't have a view
expect(secondaryLink.props('menuItem').view).toBeUndefined();
secondaryLink.vm.$emit('click');
await nextTick();
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view);
});
describe('when other view menu item is clicked', () => {
let primaryLink;
beforeEach(async () => {
primaryLink = findMenuItems().at(0);
primaryLink.vm.$emit('click');
await nextTick();
});
it('clicked on link with view', () => {
expect(primaryLink.props('menuItem').view).toBeTruthy();
});
it('changes active view', () => {
expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view);
});
it('changes active status on menu item', () => {
expect(findMenuItemGroupsModel()).toStrictEqual(
createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }),
);
});
});
});
});
import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
const TEST_MENU_ITEM = {
title: 'Cheeseburger',
icon: 'search',
href: '/pretty/good/burger',
view: 'burger-view',
};
describe('~/nav/components/top_nav_menu_item.vue', () => {
let listener;
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavMenuItem, {
propsData: {
menuItem: TEST_MENU_ITEM,
...props,
},
listeners: {
click: listener,
},
});
};
const findButton = () => wrapper.find(GlButton);
const findButtonIcons = () =>
findButton()
.findAllComponents(GlIcon)
.wrappers.map((x) => x.props('name'));
beforeEach(() => {
listener = jest.fn();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders button href and text', () => {
const button = findButton();
expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href);
expect(button.text()).toBe(TEST_MENU_ITEM.title);
});
it('passes listeners to button', () => {
expect(listener).not.toHaveBeenCalled();
findButton().vm.$emit('click', 'TEST');
expect(listener).toHaveBeenCalledWith('TEST');
});
});
describe.each`
desc | menuItem | expectedIcons
${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']}
${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']}
${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]}
${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]}
`('$desc', ({ menuItem, expectedIcons }) => {
beforeEach(() => {
createComponent({ menuItem });
});
it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => {
expect(findButtonIcons()).toEqual(expectedIcons);
});
});
});
import { range } from 'lodash';
export const TEST_NAV_DATA = {
activeTitle: 'Test Active Title',
primary: [
...['projects', 'groups'].map((view) => ({
id: view,
href: null,
title: view,
view,
})),
...range(0, 2).map((idx) => ({
id: `primary-link-${idx}`,
href: `/path/to/primary/${idx}`,
title: `Title ${idx}`,
})),
],
secondary: range(0, 2).map((idx) => ({
id: `secondary-link-${idx}`,
href: `/path/to/secondary/${idx}`,
title: `SecTitle ${idx}`,
})),
views: {
projects: {
namespace: 'projects',
currentUserName: '',
currentItem: {},
},
groups: {
namespace: 'groups',
currentUserName: '',
currentItem: {},
},
},
};
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