Commit c8684ea9 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '301143-top-nav-redesign-fe' into 'master'

Frontend for top nav menu redesign

See merge request gitlab-org/gitlab!61615
parents d55822ac 6e953772
...@@ -37,15 +37,16 @@ export const TRANSLATION_KEYS = { ...@@ -37,15 +37,16 @@ export const TRANSLATION_KEYS = {
}, },
}; };
export const FREQUENT_ITEMS_DROPDOWNS = [ export const FREQUENT_ITEMS_PROJECTS = {
{ namespace: 'projects',
namespace: 'projects', key: 'project',
key: 'project', vuexModule: 'frequentProjects',
vuexModule: 'frequentProjects', };
},
{ export const FREQUENT_ITEMS_GROUPS = {
namespace: 'groups', namespace: 'groups',
key: 'group', key: 'group',
vuexModule: 'frequentGroups', vuexModule: 'frequentGroups',
}, };
];
export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS];
...@@ -13,14 +13,16 @@ export const createFrequentItemsModule = (initState = {}) => ({ ...@@ -13,14 +13,16 @@ export const createFrequentItemsModule = (initState = {}) => ({
state: state(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 = () => { export const createStore = () => {
return new Vuex.Store({ return new Vuex.Store(createStoreOptions());
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
});
}; };
...@@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent'; ...@@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent';
import GlFieldErrors from './gl_field_errors'; import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers'; import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification'; import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import 'ee_else_ce/main_ee'; import 'ee_else_ce/main_ee';
...@@ -80,6 +81,7 @@ initRails(); ...@@ -80,6 +81,7 @@ initRails();
function deferredInitialisation() { function deferredInitialisation() {
const $body = $('body'); const $body = $('body');
initTopNav();
initBreadcrumbs(); initBreadcrumbs();
initTodoToggle(); initTodoToggle();
initLogoAnimation(); 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 { ...@@ -839,8 +839,52 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.frequent-items-dropdown-container { .frequent-items-dropdown-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 500px; height: $grid-size * 40;
height: 354px;
&.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-sidebar,
.frequent-items-dropdown-content { .frequent-items-dropdown-content {
...@@ -861,26 +905,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -861,26 +905,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: 70%; 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, .section-header,
.frequent-items-list-container li.section-empty { .frequent-items-list-container li.section-empty {
padding: 0 $gl-padding;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
font-size: $gl-font-size; font-size: $gl-font-size;
} }
...@@ -898,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -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 { .section-header {
font-weight: 700; font-weight: 700;
margin-top: 8px; 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-list-item-container {
.frequent-items-item-avatar-container, .frequent-items-item-avatar-container,
.frequent-items-item-metadata-container { .frequent-items-item-metadata-container {
float: left; flex-shrink: 0;
} }
.frequent-items-item-metadata-container { .frequent-items-item-metadata-container {
......
$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
.navbar-gitlab { .navbar-gitlab {
padding: 0 16px; padding: 0 16px;
z-index: $header-zindex; z-index: $header-zindex;
...@@ -254,6 +256,7 @@ ...@@ -254,6 +256,7 @@
} }
} }
.top-nav-toggle,
> button { > button {
background: transparent; background: transparent;
border: 0; border: 0;
...@@ -629,3 +632,36 @@ ...@@ -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; ...@@ -283,6 +283,8 @@ $indigo-700: #4b4ba3;
$indigo-800: #393982; $indigo-800: #393982;
$indigo-900: #292961; $indigo-900: #292961;
$indigo-950: #1a1a40; $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-50: #f4f8fc;
$theme-blue-100: #e6edf5; $theme-blue-100: #e6edf5;
......
...@@ -70,6 +70,7 @@ $indigo-700: #a6a6de; ...@@ -70,6 +70,7 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0; $indigo-800: #d1d1f0;
$indigo-900: #ebebfa; $indigo-900: #ebebfa;
$indigo-950: #f7f7ff; $indigo-950: #f7f7ff;
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$gray-lightest: #222; $gray-lightest: #222;
$gray-light: $gray-50; $gray-light: $gray-50;
...@@ -160,6 +161,7 @@ body.gl-dark { ...@@ -160,6 +161,7 @@ body.gl-dark {
--indigo-800: #{$indigo-800}; --indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900}; --indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950}; --indigo-950: #{$indigo-950};
--indigo-900-alpha-008: #{$indigo-900-alpha-008};
--gl-text-color: #{$gray-900}; --gl-text-color: #{$gray-900};
--border-color: #{$border-color}; --border-color: #{$border-color};
......
...@@ -189,3 +189,21 @@ $gl-line-height-42: px-to-rem(42px); ...@@ -189,3 +189,21 @@ $gl-line-height-42: px-to-rem(42px);
.gl-line-height-42 { .gl-line-height-42 {
line-height: $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 @@ ...@@ -20,7 +20,7 @@
= _('Next') = _('Next')
- if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) - if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
= render "layouts/nav/combined_menu" = render "layouts/nav/top_nav"
- else - else
- if current_user - if current_user
= render "layouts/nav/dashboard" = 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 @@ ...@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context. -# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 -# [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? - 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 .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul %ul
= nav_link(path: 'dashboard/groups#index') do = nav_link(path: 'dashboard/groups#index') do
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context. -# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 -# [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? - 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 .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul %ul
= nav_link(path: 'dashboard/projects#index') do = nav_link(path: 'dashboard/projects#index') do
......
...@@ -34136,6 +34136,9 @@ msgstr "" ...@@ -34136,6 +34136,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API." msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr "" msgstr ""
msgid "TopNav|Switch to..."
msgstr ""
msgid "Topics (optional)" msgid "Topics (optional)"
msgstr "" msgstr ""
......
...@@ -20,8 +20,6 @@ RSpec.describe 'Admin mode' do ...@@ -20,8 +20,6 @@ RSpec.describe 'Admin mode' do
context 'when not in admin mode' do context 'when not in admin mode' do
it 'has no leave admin mode button' do it 'has no leave admin mode button' do
pending_on_combined_menu_flag
visit new_admin_session_path visit new_admin_session_path
page.within('.navbar-sub-nav') do page.within('.navbar-sub-nav') do
...@@ -180,8 +178,6 @@ RSpec.describe 'Admin mode' do ...@@ -180,8 +178,6 @@ RSpec.describe 'Admin mode' do
end end
it 'shows no admin mode buttons in navbar' do it 'shows no admin mode buttons in navbar' do
pending_on_combined_menu_flag
visit admin_root_path visit admin_root_path
page.within('.navbar-sub-nav') do 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