Skip to content
代码片段 群组 项目
提交 9b5d7337 编辑于 作者: Vitaly Slobodin's avatar Vitaly Slobodin
浏览文件

Merge branch 'context-switcher-disclosure-dropdown' into 'master'

No related branches found
No related tags found
无相关合并请求
<script>
import * as Sentry from '@sentry/browser';
import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
import { trackContextAccess, formatContextSwitcherItems } from '../utils';
import { maxSize, applyMaxSize } from '../popper_max_size_modifier';
import NavItem from './nav_item.vue';
import ProjectsList from './projects_list.vue';
import GroupsList from './groups_list.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
export default {
i18n: {
......@@ -54,6 +56,8 @@ export default {
},
},
components: {
GlDisclosureDropdown,
ContextSwitcherToggle,
GlSearchBoxByType,
GlLoadingIcon,
GlAlert,
......@@ -83,6 +87,10 @@ export default {
required: false,
default: () => ({}),
},
contextHeader: {
type: Object,
required: true,
},
},
data() {
return {
......@@ -90,6 +98,7 @@ export default {
projects: [],
groups: [],
hasError: false,
isOpen: false,
};
},
computed: {
......@@ -100,16 +109,24 @@ export default {
return this.$apollo.queries.groupsAndProjects.loading;
},
},
watch: {
isOpen(isOpen) {
this.$emit('toggle', isOpen);
if (isOpen) {
this.focusInput();
}
},
},
created() {
if (this.currentContext.namespace) {
trackContextAccess(this.username, this.currentContext);
}
},
methods: {
/**
* This needs to be exposed publicly so that we can auto-focus the search input when the parent
* GlCollapse is shown.
*/
close() {
this.$refs['disclosure-dropdown'].close();
},
focusInput() {
this.$refs['search-box'].focusInput();
},
......@@ -117,13 +134,32 @@ export default {
Sentry.captureException(e);
this.hasError = true;
},
onDisclosureDropdownShown() {
this.isOpen = true;
},
onDisclosureDropdownHidden() {
this.isOpen = false;
},
},
DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
popperOptions: {
modifiers: [maxSize, applyMaxSize],
},
};
</script>
<template>
<div>
<gl-disclosure-dropdown
ref="disclosure-dropdown"
class="context-switcher gl-w-full"
placement="center"
:popper-options="$options.popperOptions"
@shown="onDisclosureDropdownShown"
@hidden="onDisclosureDropdownHidden"
>
<template #toggle>
<context-switcher-toggle :context="contextHeader" :expanded="isOpen" />
</template>
<div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white">
<gl-search-box-by-type
ref="search-box"
......@@ -144,7 +180,7 @@ export default {
{{ $options.i18n.searchError }}
</gl-alert>
<nav v-else :aria-label="$options.i18n.contextNavigation">
<ul class="gl-p-0 gl-list-style-none">
<ul class="gl-p-0 gl-m-0 gl-list-style-none">
<li v-if="!isSearch">
<div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
{{ $options.i18n.switchTo }}
......@@ -172,5 +208,5 @@ export default {
/>
</ul>
</nav>
</div>
</gl-disclosure-dropdown>
</template>
<script>
import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
export default {
components: {
......@@ -7,9 +7,6 @@ export default {
GlAvatar,
GlIcon,
},
directives: {
CollapseToggle: GlCollapseToggleDirective,
},
props: {
/*
* Contains metadata about the current view, e.g. `id`, `title` and `avatar`
......@@ -36,7 +33,6 @@ export default {
<template>
<button
v-collapse-toggle.context-switcher
type="button"
class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0"
>
......
......@@ -82,7 +82,7 @@ export default {
<gl-icon :name="collapseIcon" :size="16" />
</button>
<gl-collapse :id="collapseId" v-model="expanded">
<div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
<div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-mb-3">
{{ noResultsText }}
</div>
<items-list :aria-label="title" :items="searchResults">
......
<script>
import { GlButton, GlCollapse } from '@gitlab/ui';
import { GlButton } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings';
import { __ } from '~/locale';
......@@ -12,7 +12,6 @@ import {
import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
import ContextSwitcher from './context_switcher.vue';
import HelpCenter from './help_center.vue';
import SidebarMenu from './sidebar_menu.vue';
......@@ -20,9 +19,7 @@ import SidebarMenu from './sidebar_menu.vue';
export default {
components: {
GlButton,
GlCollapse,
UserBar,
ContextSwitcherToggle,
ContextSwitcher,
HelpCenter,
SidebarMenu,
......@@ -51,6 +48,13 @@ export default {
return this.sidebarData.current_menu_items || [];
},
},
watch: {
isCollapsed() {
if (this.isCollapsed) {
this.$refs['context-switcher'].close();
}
},
},
mounted() {
Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar);
},
......@@ -64,9 +68,6 @@ export default {
collapseSidebar() {
toggleSuperSidebarCollapsed(true, false);
},
onContextSwitcherShown() {
this.$refs['context-switcher'].focusInput();
},
onHoverAreaMouseEnter() {
this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
},
......@@ -95,6 +96,9 @@ export default {
this.onSidebarMouseEnter();
this.onSidebarMouseLeave();
},
onContextSwitcherToggled(open) {
this.contextSwitcherOpen = open;
},
},
};
</script>
......@@ -134,36 +138,28 @@ export default {
<trial-status-popover />
</div>
<div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
<context-switcher-toggle
:context="sidebarData.current_context_header"
:expanded="contextSwitcherOpen"
data-qa-selector="context_switcher"
/>
<div class="gl-flex-grow-1 gl-overflow-auto">
<gl-collapse
id="context-switcher"
v-model="contextSwitcherOpen"
data-qa-selector="context_section"
@shown="onContextSwitcherShown"
>
<context-switcher
ref="context-switcher"
:persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
:current-context="sidebarData.current_context"
/>
</gl-collapse>
<gl-collapse :visible="!contextSwitcherOpen">
<sidebar-menu
:items="menuItems"
:panel-type="sidebarData.panel_type"
:pinned-item-ids="sidebarData.pinned_items"
:update-pins-url="sidebarData.update_pins_url"
/>
<sidebar-portal-target />
</gl-collapse>
<div
class="gl-flex-grow-1"
:class="{ 'gl-overflow-auto': !contextSwitcherOpen }"
data-testid="nav-container"
>
<context-switcher
ref="context-switcher"
:persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
:current-context="sidebarData.current_context"
:context-header="sidebarData.current_context_header"
@toggle="onContextSwitcherToggled"
/>
<sidebar-menu
:items="menuItems"
:panel-type="sidebarData.panel_type"
:pinned-item-ids="sidebarData.pinned_items"
:update-pins-url="sidebarData.update_pins_url"
/>
<sidebar-portal-target />
</div>
<div class="gl-p-3">
<help-center :sidebar-data="sidebarData" />
......
import { detectOverflow } from '@popperjs/core';
/**
* These modifiers were copied from the community modifier popper-max-size-modifier
* https://www.npmjs.com/package/popper-max-size-modifier.
* We are considering upgrading Popper.js to Floating UI, at which point the behavior this
* introduces will be available out of the box.
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213
*/
export const maxSize = {
name: 'maxSize',
enabled: true,
phase: 'main',
requiresIfExists: ['offset', 'preventOverflow', 'flip'],
fn({ state, name }) {
const overflow = detectOverflow(state);
const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 };
const { width, height } = state.rects.popper;
const [basePlacement] = state.placement.split('-');
const widthProp = basePlacement === 'left' ? 'left' : 'right';
const heightProp = basePlacement === 'top' ? 'top' : 'bottom';
state.modifiersData[name] = {
width: width - overflow[widthProp] - x,
height: height - overflow[heightProp] - y,
};
},
};
export const applyMaxSize = {
name: 'applyMaxSize',
enabled: true,
phase: 'write',
requires: ['maxSize'],
fn({ state }) {
// The `maxSize` modifier provides this data
const { width, height } = state.modifiersData.maxSize;
state.elements.popper.style.maxWidth = `${width}px`;
state.elements.popper.style.maxHeight = `${height}px`;
},
};
......@@ -98,6 +98,14 @@
}
}
.context-switcher .gl-new-dropdown-custom-toggle {
width: 100%;
}
.context-switcher .gl-new-dropdown-panel {
overflow-y: auto;
}
.context-switcher-search-box input {
@include gl-font-sm;
}
......
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue';
import ContextSwitcherToggle from '~/super_sidebar/components/context_switcher_toggle.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import ProjectsList from '~/super_sidebar/components/projects_list.vue';
import GroupsList from '~/super_sidebar/components/groups_list.vue';
......@@ -31,6 +32,7 @@ const persistentLinks = [
const username = 'root';
const projectsPath = 'projectsPath';
const groupsPath = 'groupsPath';
const contextHeader = { avatar_shape: 'circle' };
Vue.use(VueApollo);
......@@ -38,6 +40,8 @@ describe('ContextSwitcher component', () => {
let wrapper;
let mockApollo;
const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findContextSwitcherToggle = () => wrapper.findComponent(ContextSwitcherToggle);
const findNavItems = () => wrapper.findAllComponents(NavItem);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findProjectsList = () => wrapper.findComponent(ProjectsList);
......@@ -72,9 +76,18 @@ describe('ContextSwitcher component', () => {
username,
projectsPath,
groupsPath,
contextHeader,
...props,
},
stubs: {
GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
template: `
<div>
<slot name="toggle" />
<slot />
</div>
`,
}),
GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
props: ['placeholder'],
methods: { focusInput: focusInputMock },
......@@ -111,7 +124,7 @@ describe('ContextSwitcher component', () => {
);
});
it('passes the correct props the frequent projects list', () => {
it('passes the correct props to the frequent projects list', () => {
expect(findProjectsList().props()).toEqual({
username,
viewAllLink: projectsPath,
......@@ -120,7 +133,7 @@ describe('ContextSwitcher component', () => {
});
});
it('passes the correct props the frequent groups list', () => {
it('passes the correct props to the frequent groups list', () => {
expect(findGroupsList().props()).toEqual({
username,
viewAllLink: groupsPath,
......@@ -129,12 +142,6 @@ describe('ContextSwitcher component', () => {
});
});
it('focuses the search input when focusInput is called', () => {
wrapper.vm.focusInput();
expect(focusInputMock).toHaveBeenCalledTimes(1);
});
it('does not trigger the search query on mount', () => {
expect(searchUserProjectsAndGroupsHandlerSuccess).not.toHaveBeenCalled();
});
......@@ -145,6 +152,45 @@ describe('ContextSwitcher component', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('passes the correct props to the toggle', () => {
expect(findContextSwitcherToggle().props('context')).toEqual(contextHeader);
expect(findContextSwitcherToggle().props('expanded')).toEqual(false);
});
it("passes Popper.js' options to the disclosure dropdown", () => {
expect(findDisclosureDropdown().props('popperOptions')).toMatchObject({
modifiers: expect.any(Array),
});
});
it('does not emit the `toggle` event initially', () => {
expect(wrapper.emitted('toggle')).toBe(undefined);
});
});
describe('visibility changes', () => {
beforeEach(() => {
createWrapper();
findDisclosureDropdown().vm.$emit('shown');
});
it('emits the `toggle` event, focuses the search input and puts the toggle in the expanded state when opened', () => {
expect(wrapper.emitted('toggle')).toHaveLength(1);
expect(wrapper.emitted('toggle')[0]).toEqual([true]);
expect(focusInputMock).toHaveBeenCalledTimes(1);
expect(findContextSwitcherToggle().props('expanded')).toBe(true);
});
it("emits the `toggle` event, does not attempt to focus the input, and resets the toggle's `expanded` props to `false` when closed", async () => {
findDisclosureDropdown().vm.$emit('hidden');
await nextTick();
expect(wrapper.emitted('toggle')).toHaveLength(2);
expect(wrapper.emitted('toggle')[1]).toEqual([false]);
expect(focusInputMock).toHaveBeenCalledTimes(1);
expect(findContextSwitcherToggle().props('expanded')).toBe(false);
});
});
describe('item access tracking', () => {
......
import { nextTick } from 'vue';
import { GlCollapse } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue';
......@@ -19,7 +18,7 @@ import { stubComponent } from 'helpers/stub_component';
import { sidebarData } from '../mock_data';
jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager');
const focusInputMock = jest.fn();
const closeContextSwitcherMock = jest.fn();
const trialStatusWidgetStubTestId = 'trial-status-widget';
const TrialStatusWidgetStub = { template: `<div data-testid="${trialStatusWidgetStubTestId}" />` };
......@@ -34,6 +33,8 @@ describe('SuperSidebar component', () => {
const findSidebar = () => wrapper.findByTestId('super-sidebar');
const findHoverArea = () => wrapper.findByTestId('super-sidebar-hover-area');
const findUserBar = () => wrapper.findComponent(UserBar);
const findContextSwitcher = () => wrapper.findComponent(ContextSwitcher);
const findNavContainer = () => wrapper.findByTestId('nav-container');
const findHelpCenter = () => wrapper.findComponent(HelpCenter);
const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget);
const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId);
......@@ -55,7 +56,7 @@ describe('SuperSidebar component', () => {
},
stubs: {
ContextSwitcher: stubComponent(ContextSwitcher, {
methods: { focusInput: focusInputMock },
methods: { close: closeContextSwitcherMock },
}),
TrialStatusWidget: TrialStatusWidgetStub,
TrialStatusPopover: TrialStatusPopoverStub,
......@@ -89,10 +90,10 @@ describe('SuperSidebar component', () => {
expect(findSidebarPortalTarget().exists()).toBe(true);
});
it("does not call the context switcher's focusInput method initially", () => {
it("does not call the context switcher's close method initially", () => {
createWrapper();
expect(focusInputMock).not.toHaveBeenCalled();
expect(closeContextSwitcherMock).not.toHaveBeenCalled();
});
it('renders hidden shortcut links', () => {
......@@ -135,6 +136,17 @@ describe('SuperSidebar component', () => {
});
});
describe('on collapse', () => {
beforeEach(() => {
createWrapper();
wrapper.vm.isCollapsed = true;
});
it('closes the context switcher', () => {
expect(closeContextSwitcherMock).toHaveBeenCalled();
});
});
describe('when peeking on hover', () => {
const peekClass = 'super-sidebar-peek';
......@@ -222,15 +234,20 @@ describe('SuperSidebar component', () => {
});
});
describe('when opening the context switcher', () => {
describe('nav container', () => {
beforeEach(() => {
createWrapper();
wrapper.findComponent(GlCollapse).vm.$emit('input', true);
wrapper.findComponent(GlCollapse).vm.$emit('shown');
});
it("calls the context switcher's focusInput method", () => {
expect(focusInputMock).toHaveBeenCalledTimes(1);
it('allows overflow while the context switcher is closed', () => {
expect(findNavContainer().classes()).toContain('gl-overflow-auto');
});
it('hides overflow when context switcher is opened', async () => {
findContextSwitcher().vm.$emit('toggle', true);
await nextTick();
expect(findNavContainer().classes()).not.toContain('gl-overflow-auto');
});
});
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册