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