Commit 85417741 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch...

Merge branch '229640-label-dropdown-should-trigger-under-the-label-input-within-the-new-epic-page' into 'master'

Fix epic label dropdown behavior when opened within the "New epic" page

See merge request gitlab-org/gitlab!37125
parents 85642364 c257138b
...@@ -19,6 +19,9 @@ export default { ...@@ -19,6 +19,9 @@ export default {
handleButtonClick(e) { handleButtonClick(e) {
if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents(); this.toggleDropdownContents();
}
if (this.isDropdownVariantStandalone) {
e.stopPropagation(); e.stopPropagation();
} }
}, },
...@@ -31,9 +34,9 @@ export default { ...@@ -31,9 +34,9 @@ export default {
class="labels-select-dropdown-button js-dropdown-button w-100 text-left" class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
@click="handleButtonClick" @click="handleButtonClick"
> >
<span class="dropdown-toggle-text flex-fill"> <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
{{ dropdownButtonText }} {{ dropdownButtonText }}
</span> </span>
<gl-icon name="chevron-down" class="float-right" /> <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
</gl-button> </gl-button>
</template> </template>
...@@ -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,16 @@ export default { ...@@ -45,6 +45,16 @@ export default {
} }
return this.labels; return this.labels;
}, },
showListContainer() {
if (this.isDropdownVariantSidebar) {
return !this.labelsFetchInProgress;
}
return true;
},
showNoMatchingResultsMessage() {
return !this.labelsFetchInProgress && !this.visibleLabels.length;
},
}, },
watch: { watch: {
searchKey(value) { searchKey(value) {
...@@ -132,6 +142,7 @@ export default { ...@@ -132,6 +142,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 +157,12 @@ export default { ...@@ -146,7 +157,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 +179,16 @@ export default { ...@@ -163,12 +179,16 @@ export default {
@clickLabel="handleLabelClick(label)" @clickLabel="handleLabelClick(label)"
/> />
</li> </li>
<li v-show="!visibleLabels.length" class="p-2 text-center"> <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-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>
......
...@@ -129,8 +129,8 @@ ...@@ -129,8 +129,8 @@
@include gl-left-0; @include gl-left-0;
@include gl-shadow-x0-y2-b4-s0; @include gl-shadow-x0-y2-b4-s0;
bottom: 100%;
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,23 +41,20 @@ describe('DropdownButton', () => { ...@@ -41,23 +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 stops 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).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
}, },
); );
}); });
......
...@@ -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