Commit 8fc4998c authored by David O'Regan's avatar David O'Regan

Merge branch '301143-step-2-top-nav-responsive' into 'master'

Part 2 - Top nav responsive view

See merge request gitlab-org/gitlab!63363
parents da131fd2 78373a26
<script>
import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants';
import { BV_DROPDOWN_SHOW, BV_DROPDOWN_HIDE } from '~/lib/utils/constants';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '../event_hub';
const TEMPORARY_PLACEHOLDER = 'Placeholder for responsive top nav';
import { resetMenuItemsActive } from '../utils/reset_menu_items_active';
import ResponsiveHeader from './responsive_header.vue';
import ResponsiveHome from './responsive_home.vue';
import TopNavContainerView from './top_nav_container_view.vue';
export default {
components: {
KeepAliveSlots,
ResponsiveHeader,
ResponsiveHome,
TopNavContainerView,
},
props: {
navData: {
type: Object,
required: true,
},
},
data() {
return {
activeView: 'home',
hasMobileOverlay: false,
};
},
computed: {
nav() {
return resetMenuItemsActive(this.navData);
},
},
created() {
eventHub.$on(EVENT_RESPONSIVE_TOGGLE, this.onToggle);
this.$root.$on(BV_DROPDOWN_SHOW, this.showMobileOverlay);
this.$root.$on(BV_DROPDOWN_HIDE, this.hideMobileOverlay);
},
beforeDestroy() {
eventHub.$off(EVENT_RESPONSIVE_TOGGLE, this.onToggle);
this.$root.$off(BV_DROPDOWN_SHOW, this.showMobileOverlay);
this.$root.$off(BV_DROPDOWN_HIDE, this.hideMobileOverlay);
},
methods: {
onToggle() {
document.body.classList.toggle('top-nav-responsive-open');
},
onMenuItemClick({ view }) {
if (view) {
this.activeView = view;
}
},
showMobileOverlay() {
this.hasMobileOverlay = true;
},
hideMobileOverlay() {
this.hasMobileOverlay = false;
},
},
TEMPORARY_PLACEHOLDER,
FREQUENT_ITEMS_PROJECTS,
FREQUENT_ITEMS_GROUPS,
};
</script>
<template>
<p>{{ $options.TEMPORARY_PLACEHOLDER }}</p>
<div>
<div
class="mobile-overlay"
:class="{ 'mobile-nav-open': hasMobileOverlay }"
data-testid="mobile-overlay"
></div>
<keep-alive-slots :slot-key="activeView">
<template #home>
<responsive-home :nav-data="nav" @menu-item-click="onMenuItemClick" />
</template>
<template #projects>
<responsive-header @menu-item-click="onMenuItemClick">
{{ __('Projects') }}
</responsive-header>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule"
container-class="gl-px-3"
v-bind="nav.views.projects"
/>
</template>
<template #groups>
<responsive-header @menu-item-click="onMenuItemClick">
{{ __('Groups') }}
</responsive-header>
<top-nav-container-view
:frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace"
:frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule"
container-class="gl-px-3"
v-bind="nav.views.groups"
/>
</template>
</keep-alive-slots>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import TopNavMenuItem from './top_nav_menu_item.vue';
export default {
components: {
TopNavMenuItem,
},
directives: {
GlTooltip: GlTooltipDirective,
},
computed: {
menuItem() {
return {
id: 'home',
view: 'home',
icon: 'angle-left',
};
},
},
};
</script>
<template>
<header class="gl-py-4 gl-display-flex gl-align-items-center">
<top-nav-menu-item
v-gl-tooltip="{ title: s__('TopNav|Go back') }"
class="gl-p-3!"
:menu-item="menuItem"
icon-only
@click="$emit('menu-item-click', menuItem)"
/>
<span class="gl-font-size-h2 gl-font-weight-bold gl-ml-2">
<slot></slot>
</span>
</header>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import TopNavMenuItem from './top_nav_menu_item.vue';
import TopNavMenuSections from './top_nav_menu_sections.vue';
import TopNavNewDropdown from './top_nav_new_dropdown.vue';
const NEW_VIEW = 'new';
const SEARCH_VIEW = 'search';
export default {
components: {
TopNavMenuItem,
TopNavMenuSections,
TopNavNewDropdown,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
navData: {
type: Object,
required: true,
},
},
computed: {
menuSections() {
return [
{ id: 'primary', menuItems: this.navData.primary },
{ id: 'secondary', menuItems: this.navData.secondary },
].filter((x) => x.menuItems?.length);
},
newDropdownViewModel() {
return this.navData.views[NEW_VIEW];
},
searchMenuItem() {
return this.navData.views[SEARCH_VIEW];
},
},
};
</script>
<template>
<div>
<header class="gl-display-flex gl-align-items-center gl-py-4 gl-pl-4">
<h1 class="gl-m-0 gl-font-size-h2 gl-reset-color gl-mr-auto">{{ __('Menu') }}</h1>
<top-nav-menu-item
v-if="searchMenuItem"
v-gl-tooltip="{ title: searchMenuItem.title }"
class="gl-ml-3"
:menu-item="searchMenuItem"
icon-only
/>
<top-nav-new-dropdown
v-if="newDropdownViewModel"
v-gl-tooltip="{ title: newDropdownViewModel.title }"
:view-model="newDropdownViewModel"
class="gl-ml-3"
/>
</header>
<top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" />
</div>
</template>
......@@ -20,6 +20,11 @@ export default {
type: String,
required: true,
},
containerClass: {
type: String,
required: false,
default: '',
},
linksPrimary: {
type: Array,
required: false,
......@@ -50,7 +55,11 @@ export default {
<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-container gl-w-auto"
:class="containerClass"
data-testid="frequent-items-container"
>
<div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
<vuex-module-provider :vuex-module="frequentItemsVuexModule">
<frequent-items-app v-bind="$attrs" />
......
......@@ -16,6 +16,11 @@ export default {
type: Object,
required: true,
},
iconOnly: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
dataAttrs() {
......@@ -32,13 +37,16 @@ export default {
:href="menuItem.href"
class="top-nav-menu-item gl-display-block"
:class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]"
:aria-label="menuItem.title"
v-bind="dataAttrs"
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" />
<gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-2!': !iconOnly }" />
<template v-if="!iconOnly">
{{ menuItem.title }}
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
</template>
</span>
</gl-button>
</template>
......@@ -54,6 +54,7 @@ export default {
:key="menuItem.id"
:menu-item="menuItem"
data-testid="menu-item"
class="gl-w-full"
:class="{ 'gl-mt-1': menuItemIndex > 0 }"
@click="onClick(menuItem)"
/>
......
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
},
props: {
viewModel: {
type: Object,
required: true,
},
},
computed: {
sections() {
return this.viewModel.menu_sections || [];
},
showHeaders() {
return this.sections.length > 1;
},
},
};
</script>
<template>
<gl-dropdown
toggle-class="top-nav-menu-item"
icon="plus"
:text="viewModel.title"
category="tertiary"
text-sr-only
no-caret
right
>
<template v-for="({ title, menu_items }, index) in sections">
<gl-dropdown-divider v-if="index > 0" :key="`${index}_divider`" data-testid="divider" />
<gl-dropdown-section-header v-if="showHeaders" :key="`${index}_header`" data-testid="header">
{{ title }}
</gl-dropdown-section-header>
<template v-for="menuItem in menu_items">
<gl-dropdown-item
:key="`${index}_item_${menuItem.id}`"
link-class="top-nav-menu-item"
:href="menuItem.href"
data-testid="item"
>
{{ menuItem.title }}
</gl-dropdown-item>
</template>
</template>
</gl-dropdown>
</template>
const resetActiveInArray = (arr) => arr?.map((menuItem) => ({ ...menuItem, active: false }));
/**
* This method sets `active: false` for the menu items within the given nav data.
*
* @returns navData with the menu items updated with `active: false`
*/
export const resetMenuItemsActive = ({ primary, secondary, ...navData }) => {
return {
...navData,
primary: resetActiveInArray(primary),
secondary: resetActiveInArray(secondary),
};
};
......@@ -497,6 +497,9 @@ body {
color: #dbdbdb;
vertical-align: baseline;
}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
......@@ -2179,6 +2182,12 @@ body.gl-dark {
margin-left: 0 !important;
margin-right: 0 !important;
}
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking";
@include cloak-startup-scss(none);
......@@ -482,6 +482,9 @@ body {
color: #525252;
vertical-align: baseline;
}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
......@@ -1962,6 +1965,12 @@ body.sidebar-refactoring
margin-left: 0 !important;
margin-right: 0 !important;
}
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking";
@include cloak-startup-scss(none);
......@@ -4,21 +4,58 @@ module Nav
module TopNavHelper
PROJECTS_VIEW = :projects
GROUPS_VIEW = :groups
NEW_VIEW = :new
SEARCH_VIEW = :search
def top_nav_view_model(project:, group:)
builder = ::Gitlab::Nav::TopNavViewModelBuilder.new
if current_user
build_view_model(builder: builder, project: project, group: group)
else
build_anonymous_view_model(builder: builder)
build_base_view_model(builder: builder, project: project, group: group)
builder.build
end
def top_nav_responsive_view_model(project:, group:)
builder = ::Gitlab::Nav::TopNavViewModelBuilder.new
build_base_view_model(builder: builder, project: project, group: group)
new_view_model = new_dropdown_view_model(project: project, group: group)
if new_view_model
builder.add_view(NEW_VIEW, new_view_model)
end
if top_nav_show_search
builder.add_view(SEARCH_VIEW, ::Gitlab::Nav::TopNavMenuItem.build(**top_nav_search_menu_item_attrs))
end
builder.build
end
def top_nav_show_search
header_link?(:search)
end
def top_nav_search_menu_item_attrs
{
id: 'search',
title: _('Search'),
icon: 'search',
href: search_context.search_url
}
end
private
def build_base_view_model(builder:, project:, group:)
if current_user
build_view_model(builder: builder, project: project, group: group)
else
build_anonymous_view_model(builder: builder)
end
end
def build_anonymous_view_model(builder:)
# These come from `app/views/layouts/nav/_explore.html.ham`
if explore_nav_link?(:projects)
......
......@@ -6,7 +6,7 @@
%a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid
.header-content
.title-container{ class: ('hide-when-menu-expanded' if !use_top_nav_redesign) }
.title-container.hide-when-menu-expanded
%h1.title
%span.gl-sr-only GitLab
= link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do
......@@ -33,12 +33,13 @@
%ul.nav.navbar-nav
- if current_user
= render 'layouts/header/new_dropdown', class: ('gl-display-none gl-sm-display-block' if use_top_nav_redesign)
- if header_link?(:search)
- if top_nav_show_search
- search_menu_item = top_nav_search_menu_item_attrs
%li.nav-item.d-none.d-lg-block.m-auto
= render 'layouts/search' unless current_controller?(:search)
%li.nav-item{ class: use_top_nav_redesign ? "gl-display-none gl-sm-display-inline-block gl-lg-display-none" : "gl-display-inline-block gl-lg-display-none" }
= link_to search_context.search_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('search')
= link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon(search_menu_item.fetch(:icon))
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') },
......@@ -120,9 +121,9 @@
%button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: ('gl-border-none!' if use_top_nav_redesign) }
%span.sr-only= _('Toggle navigation')
- if use_top_nav_redesign
%span.more-icon.gl-px-3
%span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold
%span.gl-pr-2= _('Menu')
= sprite_icon('dot-grid', size: 16)
= sprite_icon('hamburger', size: 16)
- else
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon')
= sprite_icon('close', size: 12, css_class: 'close-icon')
......
- return unless Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
- top_class = local_assigns.fetch(:class, nil)
- view_model = top_nav_view_model(project: @project, group: @group)
- view_model = top_nav_responsive_view_model(project: @project, group: @group)
.top-nav-responsive{ class: top_class }
#js-top-nav-responsive{ data: { view_model: view_model.to_json } }
......@@ -497,6 +497,9 @@ body {
color: #dbdbdb;
vertical-align: baseline;
}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
......@@ -2179,6 +2182,12 @@ body.gl-dark {
margin-left: 0 !important;
margin-right: 0 !important;
}
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking";
@include cloak-startup-scss(none);
......@@ -482,6 +482,9 @@ body {
color: #525252;
vertical-align: baseline;
}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
......@@ -1962,6 +1965,12 @@ body.sidebar-refactoring
margin-left: 0 !important;
margin-right: 0 !important;
}
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking";
@include cloak-startup-scss(none);
......@@ -34369,6 +34369,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr ""
msgid "TopNav|Go back"
msgstr ""
msgid "Topics (optional)"
msgstr ""
......
......@@ -6,7 +6,6 @@ RSpec.describe 'top nav responsive', :js do
include MobileHelpers
let_it_be(:user) { create(:user) }
let_it_be(:responsive_menu_text) { 'Placeholder for responsive top nav' }
before do
stub_feature_flags(combined_menu: true)
......@@ -20,7 +19,9 @@ RSpec.describe 'top nav responsive', :js do
context 'before opened' do
it 'has page content and hides responsive menu', :aggregate_failures do
expect(page).to have_css('.page-title', text: 'Projects')
expect(page).to have_no_text(responsive_menu_text)
expect(page).to have_link('Dashboard', id: 'logo')
expect(page).to have_no_css('.top-nav-responsive')
end
end
......@@ -31,8 +32,22 @@ RSpec.describe 'top nav responsive', :js do
it 'hides everything and shows responsive menu', :aggregate_failures do
expect(page).to have_no_css('.page-title', text: 'Projects')
expect(page).to have_link('Dashboard', id: 'logo')
expect(page).to have_text(responsive_menu_text)
expect(page).to have_no_link('Dashboard', id: 'logo')
within '.top-nav-responsive' do
expect(page).to have_link(nil, href: search_path)
expect(page).to have_button('Projects')
expect(page).to have_button('Groups')
expect(page).to have_link('Snippets', href: dashboard_snippets_path)
end
end
it 'has new dropdown', :aggregate_failures do
click_button('New...')
expect(page).to have_link('New project', href: new_project_path)
expect(page).to have_link('New group', href: new_group_path)
expect(page).to have_link('New snippet', href: new_snippet_path)
end
end
end
import { shallowMount } from '@vue/test-utils';
import { range } from 'lodash';
import ResponsiveApp from '~/nav/components/responsive_app.vue';
import ResponsiveHeader from '~/nav/components/responsive_header.vue';
import ResponsiveHome from '~/nav/components/responsive_home.vue';
import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '~/nav/event_hub';
import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active';
import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
import { TEST_NAV_DATA } from '../mock_data';
describe('~/nav/components/responsive_app.vue', () => {
......@@ -12,11 +17,19 @@ describe('~/nav/components/responsive_app.vue', () => {
propsData: {
navData: TEST_NAV_DATA,
},
stubs: {
KeepAliveSlots,
},
});
};
const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE);
const findHome = () => wrapper.findComponent(ResponsiveHome);
const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]');
const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader);
const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView);
const hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open');
const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open');
beforeEach(() => {
// Add test class to reset state + assert that we're adding classes correctly
......@@ -32,6 +45,13 @@ describe('~/nav/components/responsive_app.vue', () => {
createComponent();
});
it('shows home by default', () => {
expect(findHome().isVisible()).toBe(true);
expect(findHome().props()).toEqual({
navData: resetMenuItemsActive(TEST_NAV_DATA),
});
});
it.each`
times | expectation
${0} | ${false}
......@@ -45,6 +65,78 @@ describe('~/nav/components/responsive_app.vue', () => {
expect(hasBodyResponsiveOpen()).toBe(expectation);
},
);
it.each`
events | expectation
${[]} | ${false}
${['bv::dropdown::show']} | ${true}
${['bv::dropdown::show', 'bv::dropdown::hide']} | ${false}
`(
'with root events $events, movile overlay visible = $expectation',
async ({ events, expectation }) => {
// `await...reduce(async` is like doing an `forEach(async (...))` excpet it works
await events.reduce(async (acc, evt) => {
await acc;
wrapper.vm.$root.$emit(evt);
await wrapper.vm.$nextTick();
}, Promise.resolve());
expect(hasMobileOverlayVisible()).toBe(expectation);
},
);
});
const projectsContainerProps = {
containerClass: 'gl-px-3',
frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace,
frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.vuexModule,
linksPrimary: TEST_NAV_DATA.views.projects.linksPrimary,
linksSecondary: TEST_NAV_DATA.views.projects.linksSecondary,
};
const groupsContainerProps = {
containerClass: 'gl-px-3',
frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_GROUPS.namespace,
frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_GROUPS.vuexModule,
linksPrimary: TEST_NAV_DATA.views.groups.linksPrimary,
linksSecondary: TEST_NAV_DATA.views.groups.linksSecondary,
};
describe.each`
view | header | containerProps
${'projects'} | ${'Projects'} | ${projectsContainerProps}
${'groups'} | ${'Groups'} | ${groupsContainerProps}
`('when menu item with $view is clicked', ({ view, header, containerProps }) => {
beforeEach(async () => {
createComponent();
findHome().vm.$emit('menu-item-click', { view });
await wrapper.vm.$nextTick();
});
it('shows header', () => {
expect(findSubviewHeader().text()).toBe(header);
});
it('shows container subview', () => {
expect(findSubviewContainer().props()).toEqual(containerProps);
});
it('hides home', () => {
expect(findHome().isVisible()).toBe(false);
});
describe('when header back button is clicked', () => {
beforeEach(() => {
findSubviewHeader().vm.$emit('menu-item-click', { view: 'home' });
});
it('shows home', () => {
expect(findHome().isVisible()).toBe(true);
});
});
});
describe('when destroyed', () => {
......
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ResponsiveHeader from '~/nav/components/responsive_header.vue';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
const TEST_SLOT_CONTENT = 'Test slot content';
describe('~/nav/components/top_nav_menu_sections.vue', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(ResponsiveHeader, {
slots: {
default: TEST_SLOT_CONTENT,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findMenuItem = () => wrapper.findComponent(TopNavMenuItem);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders slot', () => {
expect(wrapper.text()).toBe(TEST_SLOT_CONTENT);
});
it('renders back button', () => {
const button = findMenuItem();
const tooltip = getBinding(button.element, 'gl-tooltip').value.title;
expect(tooltip).toBe('Go back');
expect(button.props()).toEqual({
menuItem: {
id: 'home',
view: 'home',
icon: 'angle-left',
},
iconOnly: true,
});
});
it('emits nothing', () => {
expect(wrapper.emitted()).toEqual({});
});
describe('when back button is clicked', () => {
beforeEach(() => {
findMenuItem().vm.$emit('click');
});
it('emits menu-item-click', () => {
expect(wrapper.emitted()).toEqual({
'menu-item-click': [[{ id: 'home', view: 'home', icon: 'angle-left' }]],
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ResponsiveHome from '~/nav/components/responsive_home.vue';
import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue';
import { TEST_NAV_DATA } from '../mock_data';
const TEST_SEARCH_MENU_ITEM = {
id: 'search',
title: 'search',
icon: 'search',
href: '/search',
};
const TEST_NEW_DROPDOWN_VIEW_MODEL = {
title: 'new',
menu_sections: [],
};
describe('~/nav/components/responsive_home.vue', () => {
let wrapper;
let menuItemClickListener;
const createComponent = (props = {}) => {
wrapper = shallowMount(ResponsiveHome, {
propsData: {
navData: TEST_NAV_DATA,
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
listeners: {
'menu-item-click': menuItemClickListener,
},
});
};
const findSearchMenuItem = () => wrapper.findComponent(TopNavMenuItem);
const findNewDropdown = () => wrapper.findComponent(TopNavNewDropdown);
const findMenuSections = () => wrapper.findComponent(TopNavMenuSections);
beforeEach(() => {
menuItemClickListener = jest.fn();
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it.each`
desc | fn
${'does not show search menu item'} | ${findSearchMenuItem}
${'does not show new dropdown'} | ${findNewDropdown}
`('$desc', ({ fn }) => {
expect(fn().exists()).toBe(false);
});
it('shows menu sections', () => {
expect(findMenuSections().props('sections')).toEqual([
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
{ id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
]);
});
it('emits when menu sections emits', () => {
expect(menuItemClickListener).not.toHaveBeenCalled();
findMenuSections().vm.$emit('menu-item-click', TEST_NAV_DATA.primary[0]);
expect(menuItemClickListener).toHaveBeenCalledWith(TEST_NAV_DATA.primary[0]);
});
});
describe('without secondary', () => {
beforeEach(() => {
createComponent({ navData: { ...TEST_NAV_DATA, secondary: null } });
});
it('shows menu sections', () => {
expect(findMenuSections().props('sections')).toEqual([
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
]);
});
});
describe('with search view', () => {
beforeEach(() => {
createComponent({
navData: {
...TEST_NAV_DATA,
views: { search: TEST_SEARCH_MENU_ITEM },
},
});
});
it('shows search menu item', () => {
expect(findSearchMenuItem().props()).toEqual({
menuItem: TEST_SEARCH_MENU_ITEM,
iconOnly: true,
});
});
it('shows tooltip for search', () => {
const tooltip = getBinding(findSearchMenuItem().element, 'gl-tooltip');
expect(tooltip.value).toEqual({ title: TEST_SEARCH_MENU_ITEM.title });
});
});
describe('with new view', () => {
beforeEach(() => {
createComponent({
navData: {
...TEST_NAV_DATA,
views: { new: TEST_NEW_DROPDOWN_VIEW_MODEL },
},
});
});
it('shows new dropdown', () => {
expect(findNewDropdown().props()).toEqual({
viewModel: TEST_NEW_DROPDOWN_VIEW_MODEL,
});
});
it('shows tooltip for new dropdown', () => {
const tooltip = getBinding(findNewDropdown().element, 'gl-tooltip');
expect(tooltip.value).toEqual({ title: TEST_NEW_DROPDOWN_VIEW_MODEL.title });
});
});
});
......@@ -13,6 +13,7 @@ const DEFAULT_PROPS = {
frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
linksPrimary: TEST_NAV_DATA.primary,
linksSecondary: TEST_NAV_DATA.secondary,
containerClass: 'test-frequent-items-container-class',
};
const TEST_OTHER_PROPS = {
namespace: 'projects',
......@@ -44,6 +45,7 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
attributes: parent.findComponent(FrequentItemsApp).attributes(),
};
};
const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]');
afterEach(() => {
wrapper.destroy();
......@@ -85,6 +87,10 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
});
});
it('renders given container class', () => {
expect(findFrequentItemsContainer().classes(DEFAULT_PROPS.containerClass)).toBe(true);
});
it('renders menu sections', () => {
const sections = [
{ id: 'primary', menuItems: TEST_NAV_DATA.primary },
......
......@@ -30,7 +30,10 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
const findButtonIcons = () =>
findButton()
.findAllComponents(GlIcon)
.wrappers.map((x) => x.props('name'));
.wrappers.map((x) => ({
name: x.props('name'),
classes: x.classes(),
}));
beforeEach(() => {
listener = jest.fn();
......@@ -65,11 +68,42 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
expect(listener).toHaveBeenCalledWith('TEST');
});
it('renders expected icons', () => {
expect(findButtonIcons()).toEqual([
{
name: TEST_MENU_ITEM.icon,
classes: ['gl-mr-2!'],
},
{
name: 'chevron-right',
classes: ['gl-ml-auto'],
},
]);
});
});
describe('with icon-only', () => {
beforeEach(() => {
createComponent({ iconOnly: true });
});
it('does not render title or view icon', () => {
expect(wrapper.text()).toBe('');
});
it('only renders menuItem icon', () => {
expect(findButtonIcons()).toEqual([
{
name: TEST_MENU_ITEM.icon,
classes: [],
},
]);
});
});
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 }} | ${[]}
......@@ -79,7 +113,7 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
});
it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => {
expect(findButtonIcons()).toEqual(expectedIcons);
expect(findButtonIcons().map((x) => x.name)).toEqual(expectedIcons);
});
});
......
......@@ -51,11 +51,11 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
menuItems: [
{
menuItem: TEST_SECTIONS[0].menuItems[0],
classes: [],
classes: ['gl-w-full'],
},
...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({
menuItem,
classes: ['gl-mt-1'],
classes: ['gl-w-full', 'gl-mt-1'],
})),
],
},
......@@ -64,11 +64,11 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
menuItems: [
{
menuItem: TEST_SECTIONS[1].menuItems[0],
classes: [],
classes: ['gl-w-full'],
},
...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({
menuItem,
classes: ['gl-mt-1'],
classes: ['gl-w-full', 'gl-mt-1'],
})),
],
},
......
import { GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue';
const TEST_VIEW_MODEL = {
title: 'Dropdown',
menu_sections: [
{
title: 'Section 1',
menu_items: [
{ id: 'foo-1', title: 'Foo 1', href: '/foo/1' },
{ id: 'foo-2', title: 'Foo 2', href: '/foo/2' },
{ id: 'foo-3', title: 'Foo 3', href: '/foo/3' },
],
},
{
title: 'Section 2',
menu_items: [
{ id: 'bar-1', title: 'Bar 1', href: '/bar/1' },
{ id: 'bar-2', title: 'Bar 2', href: '/bar/2' },
],
},
],
};
describe('~/nav/components/top_nav_menu_sections.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(TopNavNewDropdown, {
propsData: {
viewModel: TEST_VIEW_MODEL,
...props,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownContents = () =>
findDropdown()
.findAll('[data-testid]')
.wrappers.map((child) => {
const type = child.attributes('data-testid');
if (type === 'divider') {
return { type };
} else if (type === 'header') {
return { type, text: child.text() };
}
return {
type,
text: child.text(),
href: child.attributes('href'),
};
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders dropdown parent', () => {
expect(findDropdown().props()).toMatchObject({
text: TEST_VIEW_MODEL.title,
textSrOnly: true,
icon: 'plus',
});
});
it('renders dropdown content', () => {
expect(findDropdownContents()).toEqual([
{
type: 'header',
text: TEST_VIEW_MODEL.menu_sections[0].title,
},
...TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({
type: 'item',
href,
text: title,
})),
{
type: 'divider',
},
{
type: 'header',
text: TEST_VIEW_MODEL.menu_sections[1].title,
},
...TEST_VIEW_MODEL.menu_sections[1].menu_items.map(({ title, href }) => ({
type: 'item',
href,
text: title,
})),
]);
});
});
describe('with only 1 section', () => {
beforeEach(() => {
createComponent({
viewModel: {
...TEST_VIEW_MODEL,
menu_sections: TEST_VIEW_MODEL.menu_sections.slice(0, 1),
},
});
});
it('renders dropdown content without headers and dividers', () => {
expect(findDropdownContents()).toEqual(
TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({
type: 'item',
href,
text: title,
})),
);
});
});
});
......@@ -25,11 +25,15 @@ export const TEST_NAV_DATA = {
namespace: 'projects',
currentUserName: '',
currentItem: {},
linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }],
linksSecondary: [],
},
groups: {
namespace: 'groups',
currentUserName: '',
currentItem: {},
linksPrimary: [],
linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }],
},
},
};
......@@ -5,11 +5,16 @@ require 'spec_helper'
RSpec.describe Nav::TopNavHelper do
include ActionView::Helpers::UrlHelper
describe '#top_nav_view_model' do
let_it_be(:user) { build_stubbed(:user) }
let_it_be(:admin) { build_stubbed(:user, :admin) }
let_it_be(:user) { build_stubbed(:user) }
let_it_be(:admin) { build_stubbed(:user, :admin) }
let(:current_user) { nil }
before do
allow(helper).to receive(:current_user) { current_user }
end
let(:current_user) { nil }
describe '#top_nav_view_model' do
let(:current_project) { nil }
let(:current_group) { nil }
let(:with_current_settings_admin_mode) { false }
......@@ -26,7 +31,6 @@ RSpec.describe Nav::TopNavHelper do
let(:active_title) { 'Menu' }
before do
allow(helper).to receive(:current_user) { current_user }
allow(Gitlab::CurrentSettings).to receive(:admin_mode) { with_current_settings_admin_mode }
allow(helper).to receive(:header_link?).with(:admin_mode) { with_header_link_admin_mode }
allow(Gitlab::Sherlock).to receive(:enabled?) { with_sherlock_enabled }
......@@ -487,4 +491,50 @@ RSpec.describe Nav::TopNavHelper do
end
end
end
describe '#top_nav_responsive_view_model' do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:with_search) { false }
let(:with_new_view_model) { nil }
let(:subject) { helper.top_nav_responsive_view_model(project: project, group: group) }
before do
allow(helper).to receive(:header_link?).with(:search) { with_search }
allow(helper).to receive(:new_dropdown_view_model).with(project: project, group: group) { with_new_view_model }
end
it 'has nil new subview' do
expect(subject[:views][:new]).to be_nil
end
it 'has nil search subview' do
expect(subject[:views][:search]).to be_nil
end
context 'with search' do
let(:with_search) { true }
it 'has search subview' do
expect(subject[:views][:search]).to eq(
::Gitlab::Nav::TopNavMenuItem.build(
id: 'search',
title: 'Search',
icon: 'search',
href: search_path
)
)
end
end
context 'with new' do
let(:with_new_view_model) { { id: 'test-new-view-model' } }
it 'has new subview' do
expect(subject[:views][:new]).to eq({ id: 'test-new-view-model' })
end
end
end
end
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