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 {
handleButtonClick(e) {
if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
}
if (this.isDropdownVariantStandalone) {
e.stopPropagation();
}
},
......@@ -31,9 +34,9 @@ export default {
class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
@click="handleButtonClick"
>
<span class="dropdown-toggle-text flex-fill">
<span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
{{ dropdownButtonText }}
</span>
<gl-icon name="chevron-down" class="float-right" />
<gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
</gl-button>
</template>
......@@ -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,16 @@ export default {
}
return this.labels;
},
showListContainer() {
if (this.isDropdownVariantSidebar) {
return !this.labelsFetchInProgress;
}
return true;
},
showNoMatchingResultsMessage() {
return !this.labelsFetchInProgress && !this.visibleLabels.length;
},
},
watch: {
searchKey(value) {
......@@ -132,6 +142,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 +157,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 +179,16 @@ export default {
@clickLabel="handleLabelClick(label)"
/>
</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') }}
</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>
......
......@@ -129,8 +129,8 @@
@include gl-left-0;
@include gl-shadow-x0-y2-b4-s0;
bottom: 100%;
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,23 +41,20 @@ describe('DropdownButton', () => {
describe('methods', () => {
describe('handleButtonClick', () => {
it.each`
variant
${'standalone'}
${'embedded'}
variant | expectPropagationStopped
${'standalone'} | ${true}
${'embedded'} | ${false}
`(
'toggles dropdown content and stops event propagation when `state.variant` is "$variant"',
({ variant }) => {
'toggles dropdown content and handles event propagation when `state.variant` is "$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);
expect(event.stopPropagation).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