Commit 7ede4e99 authored by Axel García's avatar Axel García

Set label selector direction based on viewport

This makes the epic label selector to render on
top when the parent determines that, after loading
the content is out of viewport.
parent be9e8c53
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import { GlButton, GlIcon } from '@gitlab/ui';
export default {
......@@ -8,7 +8,6 @@ export default {
GlIcon,
},
computed: {
...mapState(['showDropdownContents']),
...mapGetters([
'dropdownButtonText',
'isDropdownVariantStandalone',
......
......@@ -9,6 +9,13 @@ export default {
DropdownContentsLabelsView,
DropdownContentsCreateView,
},
props: {
renderOnTop: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['showDropdownContentsCreateView']),
dropdownContentsView() {
......@@ -17,6 +24,13 @@ export default {
}
return 'dropdown-contents-labels-view';
},
directionStyle() {
if (this.renderOnTop) {
return { bottom: '100%' };
}
return {};
},
},
};
</script>
......@@ -24,6 +38,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
:style="directionStyle"
>
<component :is="dropdownContentsView" />
</div>
......
......@@ -45,6 +45,13 @@ export default {
}
return this.labels;
},
showListContainer() {
if (this.isDropdownVariantSidebar) {
return !this.labelsFetchInProgress;
}
return true;
},
},
watch: {
searchKey(value) {
......@@ -132,6 +139,7 @@ export default {
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
data-testid="dropdown-title"
>
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
......@@ -146,7 +154,12 @@ export default {
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
<div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
<div
v-show="showListContainer"
ref="labelsListContainer"
class="dropdown-content"
data-testid="dropdown-content"
>
<smart-virtual-list
:length="visibleLabels.length"
:remain="$options.LIST_BUFFER_SIZE"
......@@ -163,12 +176,16 @@ export default {
@clickLabel="handleLabelClick(label)"
/>
</li>
<li v-show="!visibleLabels.length" class="p-2 text-center">
<li v-show="!labelsFetchInProgress && !visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
</smart-virtual-list>
</div>
<div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-footer"
data-testid="dropdown-footer"
>
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
......
......@@ -2,6 +2,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
......@@ -100,6 +101,11 @@ export default {
default: __('Manage group labels'),
},
},
data() {
return {
contentIsOnViewport: true,
};
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
...mapGetters([
......@@ -117,6 +123,9 @@ export default {
selectedLabels,
});
},
showDropdownContents(showDropdownContents) {
this.setContentIsOnViewport(showDropdownContents);
},
},
mounted() {
this.setInitialState({
......@@ -203,6 +212,20 @@ export default {
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
setContentIsOnViewport(showDropdownContents) {
if (!this.isDropdownVariantEmbedded || !showDropdownContents) {
this.contentIsOnViewport = true;
return;
}
this.$nextTick(() => {
if (this.$refs.dropdownContents) {
const offset = { top: 100 };
this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset);
}
});
},
},
};
</script>
......@@ -239,6 +262,7 @@ export default {
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
:render-on-top="!contentIsOnViewport"
/>
</template>
</div>
......
......@@ -130,6 +130,7 @@
@include gl-shadow-x0-y2-b4-s0;
width: 300px !important;
min-height: 335px;
max-height: none;
margin-bottom: $gl-spacing-scale-6 !important;
......
---
title: Fix epic label dropdown behavior when opened within the new epic page
merge_request: 37125
author:
type: fixed
......@@ -41,28 +41,20 @@ describe('DropdownButton', () => {
describe('methods', () => {
describe('handleButtonClick', () => {
it.each`
variant
${'standalone'}
${'embedded'}
variant | expectPropagationStopped
${'standalone'} | ${true}
${'embedded'} | ${false}
`(
'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
({ variant }) => {
({ variant, expectPropagationStopped }) => {
const event = { stopPropagation: jest.fn() };
wrapper = createComponent({
...mockConfig,
variant,
});
wrapper = createComponent({ ...mockConfig, variant });
findDropdownButton().vm.$emit('click', event);
expect(store.state.showDropdownContents).toBe(true);
if (variant === 'standalone') {
expect(event.stopPropagation).toHaveBeenCalled();
} else {
expect(event.stopPropagation).not.toHaveBeenCalled();
}
expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
},
);
});
......
......@@ -17,53 +17,47 @@ import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store({
getters,
mutations,
state: {
...defaultState(),
footerCreateLabelTitle: 'Create label',
footerManageLabelTitle: 'Manage labels',
},
actions: {
...actions,
fetchLabels: jest.fn(),
},
});
store.dispatch('setInitialState', initialState);
store.dispatch('receiveLabelsSuccess', mockLabels);
return shallowMount(DropdownContentsLabelsView, {
localVue,
store,
});
};
describe('DropdownContentsLabelsView', () => {
let wrapper;
let wrapperStandalone;
let wrapperEmbedded;
beforeEach(() => {
wrapper = createComponent();
wrapperStandalone = createComponent({
...mockConfig,
variant: 'standalone',
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store({
getters,
mutations,
state: {
...defaultState(),
footerCreateLabelTitle: 'Create label',
footerManageLabelTitle: 'Manage labels',
},
actions: {
...actions,
fetchLabels: jest.fn(),
},
});
wrapperEmbedded = createComponent({
...mockConfig,
variant: 'embedded',
store.dispatch('setInitialState', initialState);
store.dispatch('receiveLabelsSuccess', mockLabels);
wrapper = shallowMount(DropdownContentsLabelsView, {
localVue,
store,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapperStandalone.destroy();
wrapperEmbedded.destroy();
wrapper = null;
});
const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
const findDropdownTitle = () => wrapper.find('[data-testid="dropdown-title"]');
const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
describe('computed', () => {
describe('visibleLabels', () => {
it('returns matching labels filtered with `searchKey`', () => {
......@@ -83,6 +77,24 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
});
});
describe('showListContainer', () => {
it.each`
variant | loading | showList
${'sidebar'} | ${false} | ${true}
${'sidebar'} | ${true} | ${false}
${'not-sidebar'} | ${true} | ${true}
${'not-sidebar'} | ${false} | ${true}
`(
'returns $showList if `state.variant` is "$variant" and `labelsFetchInProgress` is $loading',
({ variant, loading, showList }) => {
createComponent({ ...mockConfig, variant });
wrapper.vm.$store.state.labelsFetchInProgress = loading;
expect(wrapper.vm.showListContainer).toBe(showList);
},
);
});
});
describe('methods', () => {
......@@ -199,7 +211,7 @@ describe('DropdownContentsLabelsView', () => {
wrapper.vm.$store.dispatch('requestLabels');
return wrapper.vm.$nextTick(() => {
const loadingIconEl = wrapper.find(GlLoadingIcon);
const loadingIconEl = findLoadingIcon();
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
......@@ -207,22 +219,24 @@ describe('DropdownContentsLabelsView', () => {
});
it('renders dropdown title element', () => {
const titleEl = wrapper.find('.dropdown-title > span');
const titleEl = findDropdownTitle();
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe('Assign labels');
});
it('does not render dropdown title element when `state.variant` is "standalone"', () => {
expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false);
createComponent({ ...mockConfig, variant: 'standalone' });
expect(findDropdownTitle().exists()).toBe(false);
});
it('renders dropdown title element when `state.variant` is "embedded"', () => {
expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true);
createComponent({ ...mockConfig, variant: 'embedded' });
expect(findDropdownTitle().exists()).toBe(true);
});
it('renders dropdown close button element', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
const closeButtonEl = findDropdownTitle().find(GlButton);
expect(closeButtonEl.exists()).toBe(true);
expect(closeButtonEl.props('icon')).toBe('close');
......@@ -249,8 +263,7 @@ describe('DropdownContentsLabelsView', () => {
});
return wrapper.vm.$nextTick(() => {
const labelsEl = wrapper.findAll('.dropdown-content li');
const labelItemEl = labelsEl.at(0).find(LabelItem);
const labelItemEl = findDropdownContent().find(LabelItem);
expect(labelItemEl.props('highlight')).toBe(true);
});
......@@ -262,22 +275,28 @@ describe('DropdownContentsLabelsView', () => {
});
return wrapper.vm.$nextTick(() => {
const noMatchEl = wrapper.find('.dropdown-content li');
const noMatchEl = findDropdownContent().find('li');
expect(noMatchEl.isVisible()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
it('renders empty content while loading', () => {
wrapper.vm.$store.state.labelsFetchInProgress = true;
return wrapper.vm.$nextTick(() => {
const dropdownContent = findDropdownContent();
expect(dropdownContent.exists()).toBe(true);
expect(dropdownContent.isVisible()).toBe(false);
});
});
it('renders footer list items', () => {
const createLabelLink = wrapper
.find('.dropdown-footer')
.findAll(GlLink)
.at(0);
const manageLabelsLink = wrapper
.find('.dropdown-footer')
.findAll(GlLink)
.at(1);
const footerLinks = findDropdownFooter().findAll(GlLink);
const createLabelLink = footerLinks.at(0);
const manageLabelsLink = footerLinks.at(1);
expect(createLabelLink.exists()).toBe(true);
expect(createLabelLink.text()).toBe('Create label');
......@@ -289,8 +308,7 @@ describe('DropdownContentsLabelsView', () => {
wrapper.vm.$store.state.allowLabelCreate = false;
return wrapper.vm.$nextTick(() => {
const createLabelLink = wrapper
.find('.dropdown-footer')
const createLabelLink = findDropdownFooter()
.findAll(GlLink)
.at(0);
......@@ -299,11 +317,12 @@ describe('DropdownContentsLabelsView', () => {
});
it('does not render footer list items when `state.variant` is "standalone"', () => {
expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false);
createComponent({ ...mockConfig, variant: 'standalone' });
expect(findDropdownFooter().exists()).toBe(false);
});
it('renders footer list items when `state.variant` is "embedded"', () => {
expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true);
expect(findDropdownFooter().exists()).toBe(true);
});
});
});
......@@ -10,12 +10,13 @@ import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const createComponent = (initialState = mockConfig, propsData = {}) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContents, {
propsData,
localVue,
store,
});
......@@ -47,8 +48,15 @@ describe('DropdownContent', () => {
});
describe('template', () => {
it('renders component container element with class `labels-select-dropdown-contents`', () => {
it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
expect(wrapper.attributes('style')).toBe(undefined);
});
it('renders component container element with styles when `renderOnTop` is true', () => {
wrapper = createComponent(mockConfig, { renderOnTop: true });
expect(wrapper.attributes('style')).toContain('bottom: 100%');
});
});
});
......@@ -9,9 +9,14 @@ import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dr
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { isInViewport } from '~/lib/utils/common_utils';
import { mockConfig } from './mock_data';
jest.mock('~/lib/utils/common_utils', () => ({
isInViewport: jest.fn().mockReturnValue(true),
}));
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -21,6 +26,9 @@ const createComponent = (config = mockConfig, slots = {}) =>
slots,
store: new Vuex.Store(labelsSelectModule()),
propsData: config,
stubs: {
'dropdown-contents': DropdownContents,
},
});
describe('LabelsSelectRoot', () => {
......@@ -144,5 +152,42 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.find(DropdownContents).exists()).toBe(true);
});
});
describe('sets content direction based on viewport', () => {
it('does not set direction when `state.variant` is not "embedded"', () => {
wrapper.vm.$store.dispatch('toggleDropdownContents');
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
});
});
describe('when `state.variant` is "embedded"', () => {
beforeEach(() => {
wrapper = createComponent({ ...mockConfig, variant: 'embedded' });
wrapper.vm.$store.dispatch('toggleDropdownContents');
});
it('set direction when out of viewport', () => {
isInViewport.mockImplementation(() => false);
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
});
});
it('does not set direction when inside of viewport', () => {
isInViewport.mockImplementation(() => true);
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
});
});
});
});
});
});
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