Commit 78373a26 authored by Paul Slaughter's avatar Paul Slaughter Committed by David O'Regan

Part 2 - Top nav responsive view

parent 3f08f01e
<script> <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'; import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '../event_hub';
import { resetMenuItemsActive } from '../utils/reset_menu_items_active';
const TEMPORARY_PLACEHOLDER = 'Placeholder for responsive top nav'; import ResponsiveHeader from './responsive_header.vue';
import ResponsiveHome from './responsive_home.vue';
import TopNavContainerView from './top_nav_container_view.vue';
export default { export default {
components: {
KeepAliveSlots,
ResponsiveHeader,
ResponsiveHome,
TopNavContainerView,
},
props: { props: {
navData: { navData: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
data() {
return {
activeView: 'home',
hasMobileOverlay: false,
};
},
computed: {
nav() {
return resetMenuItemsActive(this.navData);
},
},
created() { created() {
eventHub.$on(EVENT_RESPONSIVE_TOGGLE, this.onToggle); eventHub.$on(EVENT_RESPONSIVE_TOGGLE, this.onToggle);
this.$root.$on(BV_DROPDOWN_SHOW, this.showMobileOverlay);
this.$root.$on(BV_DROPDOWN_HIDE, this.hideMobileOverlay);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off(EVENT_RESPONSIVE_TOGGLE, this.onToggle); eventHub.$off(EVENT_RESPONSIVE_TOGGLE, this.onToggle);
this.$root.$off(BV_DROPDOWN_SHOW, this.showMobileOverlay);
this.$root.$off(BV_DROPDOWN_HIDE, this.hideMobileOverlay);
}, },
methods: { methods: {
onToggle() { onToggle() {
document.body.classList.toggle('top-nav-responsive-open'); 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> </script>
<template> <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> </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 { ...@@ -20,6 +20,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
containerClass: {
type: String,
required: false,
default: '',
},
linksPrimary: { linksPrimary: {
type: Array, type: Array,
required: false, required: false,
...@@ -50,7 +55,11 @@ export default { ...@@ -50,7 +55,11 @@ export default {
<template> <template>
<div class="top-nav-container-view gl-display-flex gl-flex-direction-column"> <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!"> <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
<vuex-module-provider :vuex-module="frequentItemsVuexModule"> <vuex-module-provider :vuex-module="frequentItemsVuexModule">
<frequent-items-app v-bind="$attrs" /> <frequent-items-app v-bind="$attrs" />
......
...@@ -16,6 +16,11 @@ export default { ...@@ -16,6 +16,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
iconOnly: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
dataAttrs() { dataAttrs() {
...@@ -32,13 +37,16 @@ export default { ...@@ -32,13 +37,16 @@ export default {
:href="menuItem.href" :href="menuItem.href"
class="top-nav-menu-item gl-display-block" class="top-nav-menu-item gl-display-block"
:class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]" :class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]"
:aria-label="menuItem.title"
v-bind="dataAttrs" v-bind="dataAttrs"
v-on="$listeners" v-on="$listeners"
> >
<span class="gl-display-flex"> <span class="gl-display-flex">
<gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" /> <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-2!': !iconOnly }" />
{{ menuItem.title }} <template v-if="!iconOnly">
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> {{ menuItem.title }}
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
</template>
</span> </span>
</gl-button> </gl-button>
</template> </template>
...@@ -54,6 +54,7 @@ export default { ...@@ -54,6 +54,7 @@ export default {
:key="menuItem.id" :key="menuItem.id"
:menu-item="menuItem" :menu-item="menuItem"
data-testid="menu-item" data-testid="menu-item"
class="gl-w-full"
:class="{ 'gl-mt-1': menuItemIndex > 0 }" :class="{ 'gl-mt-1': menuItemIndex > 0 }"
@click="onClick(menuItem)" @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 { ...@@ -497,6 +497,9 @@ body {
color: #dbdbdb; color: #dbdbdb;
vertical-align: baseline; vertical-align: baseline;
} }
.gl-font-sm {
font-size: 12px;
}
.dropdown { .dropdown {
position: relative; position: relative;
} }
...@@ -2179,6 +2182,12 @@ body.gl-dark { ...@@ -2179,6 +2182,12 @@ body.gl-dark {
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
} }
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking"; @import "startup/cloaking";
@include cloak-startup-scss(none); @include cloak-startup-scss(none);
...@@ -482,6 +482,9 @@ body { ...@@ -482,6 +482,9 @@ body {
color: #525252; color: #525252;
vertical-align: baseline; vertical-align: baseline;
} }
.gl-font-sm {
font-size: 12px;
}
.dropdown { .dropdown {
position: relative; position: relative;
} }
...@@ -1962,6 +1965,12 @@ body.sidebar-refactoring ...@@ -1962,6 +1965,12 @@ body.sidebar-refactoring
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
} }
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking"; @import "startup/cloaking";
@include cloak-startup-scss(none); @include cloak-startup-scss(none);
...@@ -4,21 +4,58 @@ module Nav ...@@ -4,21 +4,58 @@ module Nav
module TopNavHelper module TopNavHelper
PROJECTS_VIEW = :projects PROJECTS_VIEW = :projects
GROUPS_VIEW = :groups GROUPS_VIEW = :groups
NEW_VIEW = :new
SEARCH_VIEW = :search
def top_nav_view_model(project:, group:) def top_nav_view_model(project:, group:)
builder = ::Gitlab::Nav::TopNavViewModelBuilder.new builder = ::Gitlab::Nav::TopNavViewModelBuilder.new
if current_user build_base_view_model(builder: builder, project: project, group: group)
build_view_model(builder: builder, project: project, group: group)
else builder.build
build_anonymous_view_model(builder: builder) 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 end
builder.build builder.build
end 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 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:) def build_anonymous_view_model(builder:)
# These come from `app/views/layouts/nav/_explore.html.ham` # These come from `app/views/layouts/nav/_explore.html.ham`
if explore_nav_link?(:projects) if explore_nav_link?(:projects)
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
%a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid .container-fluid
.header-content .header-content
.title-container{ class: ('hide-when-menu-expanded' if !use_top_nav_redesign) } .title-container.hide-when-menu-expanded
%h1.title %h1.title
%span.gl-sr-only GitLab %span.gl-sr-only GitLab
= link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do = link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do
...@@ -33,12 +33,13 @@ ...@@ -33,12 +33,13 @@
%ul.nav.navbar-nav %ul.nav.navbar-nav
- if current_user - if current_user
= render 'layouts/header/new_dropdown', class: ('gl-display-none gl-sm-display-block' if use_top_nav_redesign) = 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 %li.nav-item.d-none.d-lg-block.m-auto
= render 'layouts/search' unless current_controller?(:search) = 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" } %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 = 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') = sprite_icon(search_menu_item.fetch(:icon))
- if header_link?(:issues) - if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do = 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') }, = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') },
...@@ -120,9 +121,9 @@ ...@@ -120,9 +121,9 @@
%button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: ('gl-border-none!' if use_top_nav_redesign) } %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: ('gl-border-none!' if use_top_nav_redesign) }
%span.sr-only= _('Toggle navigation') %span.sr-only= _('Toggle navigation')
- if use_top_nav_redesign - 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') %span.gl-pr-2= _('Menu')
= sprite_icon('dot-grid', size: 16) = sprite_icon('hamburger', size: 16)
- else - else
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon') = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon')
= sprite_icon('close', size: 12, css_class: 'close-icon') = sprite_icon('close', size: 12, css_class: 'close-icon')
......
- return unless Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) - return unless Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
- top_class = local_assigns.fetch(:class, nil) - 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 } .top-nav-responsive{ class: top_class }
#js-top-nav-responsive{ data: { view_model: view_model.to_json } } #js-top-nav-responsive{ data: { view_model: view_model.to_json } }
...@@ -497,6 +497,9 @@ body { ...@@ -497,6 +497,9 @@ body {
color: #dbdbdb; color: #dbdbdb;
vertical-align: baseline; vertical-align: baseline;
} }
.gl-font-sm {
font-size: 12px;
}
.dropdown { .dropdown {
position: relative; position: relative;
} }
...@@ -2179,6 +2182,12 @@ body.gl-dark { ...@@ -2179,6 +2182,12 @@ body.gl-dark {
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
} }
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking"; @import "startup/cloaking";
@include cloak-startup-scss(none); @include cloak-startup-scss(none);
...@@ -482,6 +482,9 @@ body { ...@@ -482,6 +482,9 @@ body {
color: #525252; color: #525252;
vertical-align: baseline; vertical-align: baseline;
} }
.gl-font-sm {
font-size: 12px;
}
.dropdown { .dropdown {
position: relative; position: relative;
} }
...@@ -1962,6 +1965,12 @@ body.sidebar-refactoring ...@@ -1962,6 +1965,12 @@ body.sidebar-refactoring
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
} }
.gl-font-sm {
font-size: 0.75rem;
}
.gl-font-weight-bold {
font-weight: 600;
}
@import "startup/cloaking"; @import "startup/cloaking";
@include cloak-startup-scss(none); @include cloak-startup-scss(none);
...@@ -34369,6 +34369,9 @@ msgstr "" ...@@ -34369,6 +34369,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|Go back"
msgstr ""
msgid "Topics (optional)" msgid "Topics (optional)"
msgstr "" msgstr ""
......
...@@ -6,7 +6,6 @@ RSpec.describe 'top nav responsive', :js do ...@@ -6,7 +6,6 @@ RSpec.describe 'top nav responsive', :js do
include MobileHelpers include MobileHelpers
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:responsive_menu_text) { 'Placeholder for responsive top nav' }
before do before do
stub_feature_flags(combined_menu: true) stub_feature_flags(combined_menu: true)
...@@ -20,7 +19,9 @@ RSpec.describe 'top nav responsive', :js do ...@@ -20,7 +19,9 @@ RSpec.describe 'top nav responsive', :js do
context 'before opened' do context 'before opened' do
it 'has page content and hides responsive menu', :aggregate_failures 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_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
end end
...@@ -31,8 +32,22 @@ RSpec.describe 'top nav responsive', :js do ...@@ -31,8 +32,22 @@ RSpec.describe 'top nav responsive', :js do
it 'hides everything and shows responsive menu', :aggregate_failures 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_no_css('.page-title', text: 'Projects')
expect(page).to have_link('Dashboard', id: 'logo') expect(page).to have_no_link('Dashboard', id: 'logo')
expect(page).to have_text(responsive_menu_text)
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 end
end end
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { range } from 'lodash'; import { range } from 'lodash';
import ResponsiveApp from '~/nav/components/responsive_app.vue'; 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 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'; import { TEST_NAV_DATA } from '../mock_data';
describe('~/nav/components/responsive_app.vue', () => { describe('~/nav/components/responsive_app.vue', () => {
...@@ -12,11 +17,19 @@ describe('~/nav/components/responsive_app.vue', () => { ...@@ -12,11 +17,19 @@ describe('~/nav/components/responsive_app.vue', () => {
propsData: { propsData: {
navData: TEST_NAV_DATA, navData: TEST_NAV_DATA,
}, },
stubs: {
KeepAliveSlots,
},
}); });
}; };
const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE); 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 hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open');
const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open');
beforeEach(() => { beforeEach(() => {
// Add test class to reset state + assert that we're adding classes correctly // Add test class to reset state + assert that we're adding classes correctly
...@@ -32,6 +45,13 @@ describe('~/nav/components/responsive_app.vue', () => { ...@@ -32,6 +45,13 @@ describe('~/nav/components/responsive_app.vue', () => {
createComponent(); createComponent();
}); });
it('shows home by default', () => {
expect(findHome().isVisible()).toBe(true);
expect(findHome().props()).toEqual({
navData: resetMenuItemsActive(TEST_NAV_DATA),
});
});
it.each` it.each`
times | expectation times | expectation
${0} | ${false} ${0} | ${false}
...@@ -45,6 +65,78 @@ describe('~/nav/components/responsive_app.vue', () => { ...@@ -45,6 +65,78 @@ describe('~/nav/components/responsive_app.vue', () => {
expect(hasBodyResponsiveOpen()).toBe(expectation); 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', () => { 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 = { ...@@ -13,6 +13,7 @@ const DEFAULT_PROPS = {
frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule, frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
linksPrimary: TEST_NAV_DATA.primary, linksPrimary: TEST_NAV_DATA.primary,
linksSecondary: TEST_NAV_DATA.secondary, linksSecondary: TEST_NAV_DATA.secondary,
containerClass: 'test-frequent-items-container-class',
}; };
const TEST_OTHER_PROPS = { const TEST_OTHER_PROPS = {
namespace: 'projects', namespace: 'projects',
...@@ -44,6 +45,7 @@ describe('~/nav/components/top_nav_container_view.vue', () => { ...@@ -44,6 +45,7 @@ describe('~/nav/components/top_nav_container_view.vue', () => {
attributes: parent.findComponent(FrequentItemsApp).attributes(), attributes: parent.findComponent(FrequentItemsApp).attributes(),
}; };
}; };
const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]');
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -85,6 +87,10 @@ describe('~/nav/components/top_nav_container_view.vue', () => { ...@@ -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', () => { it('renders menu sections', () => {
const sections = [ const sections = [
{ id: 'primary', menuItems: TEST_NAV_DATA.primary }, { id: 'primary', menuItems: TEST_NAV_DATA.primary },
......
...@@ -30,7 +30,10 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { ...@@ -30,7 +30,10 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
const findButtonIcons = () => const findButtonIcons = () =>
findButton() findButton()
.findAllComponents(GlIcon) .findAllComponents(GlIcon)
.wrappers.map((x) => x.props('name')); .wrappers.map((x) => ({
name: x.props('name'),
classes: x.classes(),
}));
beforeEach(() => { beforeEach(() => {
listener = jest.fn(); listener = jest.fn();
...@@ -65,11 +68,42 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { ...@@ -65,11 +68,42 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
expect(listener).toHaveBeenCalledWith('TEST'); 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` describe.each`
desc | menuItem | expectedIcons 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 icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']}
${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]} ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]}
${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]} ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]}
...@@ -79,7 +113,7 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { ...@@ -79,7 +113,7 @@ describe('~/nav/components/top_nav_menu_item.vue', () => {
}); });
it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => { 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', () => { ...@@ -51,11 +51,11 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
menuItems: [ menuItems: [
{ {
menuItem: TEST_SECTIONS[0].menuItems[0], menuItem: TEST_SECTIONS[0].menuItems[0],
classes: [], classes: ['gl-w-full'],
}, },
...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({ ...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({
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', () => { ...@@ -64,11 +64,11 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => {
menuItems: [ menuItems: [
{ {
menuItem: TEST_SECTIONS[1].menuItems[0], menuItem: TEST_SECTIONS[1].menuItems[0],
classes: [], classes: ['gl-w-full'],
}, },
...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({ ...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({
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 = { ...@@ -25,11 +25,15 @@ export const TEST_NAV_DATA = {
namespace: 'projects', namespace: 'projects',
currentUserName: '', currentUserName: '',
currentItem: {}, currentItem: {},
linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }],
linksSecondary: [],
}, },
groups: { groups: {
namespace: 'groups', namespace: 'groups',
currentUserName: '', currentUserName: '',
currentItem: {}, currentItem: {},
linksPrimary: [],
linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }],
}, },
}, },
}; };
...@@ -5,11 +5,16 @@ require 'spec_helper' ...@@ -5,11 +5,16 @@ require 'spec_helper'
RSpec.describe Nav::TopNavHelper do RSpec.describe Nav::TopNavHelper do
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
describe '#top_nav_view_model' do let_it_be(:user) { build_stubbed(:user) }
let_it_be(:user) { build_stubbed(:user) } let_it_be(:admin) { build_stubbed(:user, :admin) }
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_project) { nil }
let(:current_group) { nil } let(:current_group) { nil }
let(:with_current_settings_admin_mode) { false } let(:with_current_settings_admin_mode) { false }
...@@ -26,7 +31,6 @@ RSpec.describe Nav::TopNavHelper do ...@@ -26,7 +31,6 @@ RSpec.describe Nav::TopNavHelper do
let(:active_title) { 'Menu' } let(:active_title) { 'Menu' }
before do before do
allow(helper).to receive(:current_user) { current_user }
allow(Gitlab::CurrentSettings).to receive(:admin_mode) { with_current_settings_admin_mode } 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(helper).to receive(:header_link?).with(:admin_mode) { with_header_link_admin_mode }
allow(Gitlab::Sherlock).to receive(:enabled?) { with_sherlock_enabled } allow(Gitlab::Sherlock).to receive(:enabled?) { with_sherlock_enabled }
...@@ -487,4 +491,50 @@ RSpec.describe Nav::TopNavHelper do ...@@ -487,4 +491,50 @@ RSpec.describe Nav::TopNavHelper do
end end
end 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 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