Commit b060e23f authored by Kushal Pandya's avatar Kushal Pandya

Fix labels select dropdown search scroll

Avoid using VirtualList for rendering labels list while
performing search to prevent janky scroll.
parent f9e98deb
...@@ -3,5 +3,3 @@ export const DropdownVariant = { ...@@ -3,5 +3,3 @@ export const DropdownVariant = {
Standalone: 'standalone', Standalone: 'standalone',
Embedded: 'embedded', Embedded: 'embedded',
}; };
export const LIST_BUFFER_SIZE = 5;
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import {
GlIntersectionObserver,
GlLoadingIcon,
GlButton,
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import LabelItem from './label_item.vue'; import LabelItem from './label_item.vue';
import { LIST_BUFFER_SIZE } from './constants';
export default { export default {
LIST_BUFFER_SIZE,
components: { components: {
GlIntersectionObserver,
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
GlSearchBoxByType, GlSearchBoxByType,
GlLink, GlLink,
SmartVirtualList,
LabelItem, LabelItem,
}, },
data() { data() {
...@@ -46,15 +48,8 @@ export default { ...@@ -46,15 +48,8 @@ export default {
} }
return this.labels; return this.labels;
}, },
showListContainer() {
if (this.isDropdownVariantSidebar) {
return !this.labelsFetchInProgress;
}
return true;
},
showNoMatchingResultsMessage() { showNoMatchingResultsMessage() {
return !this.labelsFetchInProgress && !this.visibleLabels.length; return Boolean(this.searchKey) && this.visibleLabels.length === 0;
}, },
}, },
watch: { watch: {
...@@ -67,14 +62,12 @@ export default { ...@@ -67,14 +62,12 @@ export default {
} }
}, },
}, },
mounted() {
this.fetchLabels();
},
methods: { methods: {
...mapActions([ ...mapActions([
'toggleDropdownContents', 'toggleDropdownContents',
'toggleDropdownContentsCreateView', 'toggleDropdownContentsCreateView',
'fetchLabels', 'fetchLabels',
'receiveLabelsSuccess',
'updateSelectedLabels', 'updateSelectedLabels',
'toggleDropdownContents', 'toggleDropdownContents',
]), ]),
...@@ -99,6 +92,17 @@ export default { ...@@ -99,6 +92,17 @@ export default {
} }
} }
}, },
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
*/
handleComponentDisappear() {
this.receiveLabelsSuccess([]);
},
handleCreateLabelClick() {
this.receiveLabelsSuccess([]);
this.toggleDropdownContentsCreateView();
},
/** /**
* This method enables keyboard navigation support for * This method enables keyboard navigation support for
* the dropdown. * the dropdown.
...@@ -135,84 +139,75 @@ export default { ...@@ -135,84 +139,75 @@ export default {
</script> </script>
<template> <template>
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear">
<gl-loading-icon <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
v-if="labelsFetchInProgress" <div
class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
size="md" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
/> data-testid="dropdown-title"
<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
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
v-model="searchKey"
:autofocus="true"
data-qa-selector="dropdown_input_field"
/>
</div>
<div
v-show="showListContainer"
ref="labelsListContainer"
class="dropdown-content"
data-testid="dropdown-content"
>
<smart-virtual-list
:length="visibleLabels.length"
:remain="$options.LIST_BUFFER_SIZE"
:size="$options.LIST_BUFFER_SIZE"
wclass="list-unstyled mb-0"
wtag="ul"
class="h-100"
> >
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> <span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
v-model="searchKey"
:autofocus="true"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
</div>
<div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center w-100 h-100"
size="md"
/>
<ul v-else class="list-unstyled mb-0">
<label-item <label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:label="label" :label="label"
:is-label-set="label.set" :is-label-set="label.set"
:highlight="index === currentHighlightItem" :highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)" @clickLabel="handleLabelClick(label)"
/> />
</li> <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
<li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> {{ __('No matching results') }}
{{ __('No matching results') }} </li>
</li> </ul>
</smart-virtual-list> </div>
</div> <div
<div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"
class="dropdown-footer" data-testid="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 class="gl-display-flex w-100 flex-row text-break-word label-item"
class="gl-display-flex w-100 flex-row text-break-word label-item" @click="handleCreateLabelClick"
@click="toggleDropdownContentsCreateView" >
> {{ footerCreateLabelTitle }}
{{ footerCreateLabelTitle }} </gl-link>
</gl-link> </li>
</li> <li>
<li> <gl-link
<gl-link :href="labelsManagePath"
:href="labelsManagePath" class="gl-display-flex flex-row text-break-word label-item"
class="gl-display-flex flex-row text-break-word label-item" >
> {{ footerManageLabelTitle }}
{{ footerManageLabelTitle }} </gl-link>
</gl-link> </li>
</li> </ul>
</ul> </div>
</div> </div>
</div> </gl-intersection-observer>
</template> </template>
<script> <script>
import { GlIcon, GlLink } from '@gitlab/ui'; import { GlLink, GlIcon } from '@gitlab/ui';
export default { export default {
components: { functional: true,
GlIcon,
GlLink,
},
props: { props: {
label: { label: {
type: Object, type: Object,
...@@ -21,46 +18,65 @@ export default { ...@@ -21,46 +18,65 @@ export default {
default: false, default: false,
}, },
}, },
data() { render(h, { props, listeners }) {
return { const { label, highlight, isLabelSet } = props;
isSet: this.isLabelSet,
}; const labelColorBox = h('span', {
}, class: 'dropdown-label-box',
computed: { style: {
labelBoxStyle() { backgroundColor: label.color,
return { },
backgroundColor: this.label.color, attrs: {
}; 'data-testid': 'label-color-box',
}, },
}, });
watch: {
/** const checkedIcon = h(GlIcon, {
* This watcher assures that if user used class: {
* `Enter` key to set/unset label, changes 'mr-2 align-self-center': true,
* are reflected here too. hidden: !isLabelSet,
*/ },
isLabelSet(value) { props: {
this.isSet = value; name: 'mobile-issue-close',
}, },
}, });
methods: {
handleClick() { const noIcon = h('span', {
this.isSet = !this.isSet; class: {
this.$emit('clickLabel', this.label); 'mr-3 pr-2': true,
}, hidden: isLabelSet,
},
attrs: {
'data-testid': 'no-icon',
},
});
const labelTitle = h('span', label.title);
const labelLink = h(
GlLink,
{
class: 'd-flex align-items-baseline text-break-word label-item',
on: {
click: () => {
listeners.clickLabel(label);
},
},
},
[noIcon, checkedIcon, labelColorBox, labelTitle],
);
return h(
'li',
{
class: {
'd-block': true,
'text-left': true,
'is-focused': highlight,
},
},
[labelLink],
);
}, },
}; };
</script> </script>
<template>
<gl-link
class="d-flex align-items-baseline text-break-word label-item"
:class="{ 'is-focused': highlight }"
@click="handleClick"
>
<gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" />
<span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span>
<span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span>
<span>{{ label.title }}</span>
</gl-link>
</template>
...@@ -266,7 +266,7 @@ export default { ...@@ -266,7 +266,7 @@ export default {
</dropdown-value> </dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
<dropdown-contents <dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents" v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents" ref="dropdownContents"
/> />
</template> </template>
......
...@@ -1017,6 +1017,23 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1017,6 +1017,23 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
} }
} }
li {
&:hover,
&.is-focused {
.label-item {
@include dropdown-item-hover;
text-decoration: none;
}
}
}
.labels-select-dropdown-button {
.gl-button-text {
width: 100%;
}
}
.labels-select-dropdown-contents { .labels-select-dropdown-contents {
min-height: $dropdown-min-height; min-height: $dropdown-min-height;
max-height: 330px; max-height: 330px;
...@@ -1050,13 +1067,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1050,13 +1067,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.label-item { .label-item {
padding: 8px 20px; padding: 8px 20px;
&:hover,
&.is-focused {
@include dropdown-item-hover;
text-decoration: none;
}
} }
.color-input-container { .color-input-container {
......
---
title: Fix Vue Labels Select dropdown keyboard scroll
merge_request: 43874
author:
type: fixed
...@@ -149,6 +149,7 @@ export default { ...@@ -149,6 +149,7 @@ export default {
<sidebar-todo <sidebar-todo
v-show="sidebarCollapsed && isUserSignedIn" v-show="sidebarCollapsed && isUserSignedIn"
:sidebar-collapsed="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed"
data-testid="todo"
/> />
<sidebar-date-picker <sidebar-date-picker
v-show="!sidebarCollapsed" v-show="!sidebarCollapsed"
...@@ -167,6 +168,7 @@ export default { ...@@ -167,6 +168,7 @@ export default {
:date-from-milestones="startDateTimeFromMilestones" :date-from-milestones="startDateTimeFromMilestones"
:selected-date="startDateTime" :selected-date="startDateTime"
:is-date-invalid="isDateInvalid" :is-date-invalid="isDateInvalid"
data-testid="start-date"
block-class="start-date" block-class="start-date"
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
@toggleDateType="changeStartDateType" @toggleDateType="changeStartDateType"
...@@ -188,6 +190,7 @@ export default { ...@@ -188,6 +190,7 @@ export default {
:date-from-milestones="dueDateTimeFromMilestones" :date-from-milestones="dueDateTimeFromMilestones"
:selected-date="dueDateTime" :selected-date="dueDateTime"
:is-date-invalid="isDateInvalid" :is-date-invalid="isDateInvalid"
data-testid="due-date"
block-class="due-date" block-class="due-date"
@toggleDateType="changeDueDateType" @toggleDateType="changeDueDateType"
@saveDate="saveDueDate" @saveDate="saveDueDate"
...@@ -199,9 +202,13 @@ export default { ...@@ -199,9 +202,13 @@ export default {
:max-date="dueDateForCollapsedSidebar" :max-date="dueDateForCollapsedSidebar"
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/> />
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" /> <sidebar-labels
:can-update="canUpdate"
:sidebar-collapsed="sidebarCollapsed"
data-testid="labels-select"
/>
<div v-if="allowSubEpics" class="block ancestors"> <div v-if="allowSubEpics" class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" /> <ancestors-tree :ancestors="ancestors" :is-fetching="false" data-testid="ancestors" />
</div> </div>
<confidential-issue-sidebar <confidential-issue-sidebar
...@@ -216,7 +223,7 @@ export default { ...@@ -216,7 +223,7 @@ export default {
@toggleSidebar="toggleSidebar({ sidebarCollapsed })" @toggleSidebar="toggleSidebar({ sidebarCollapsed })"
/> />
</div> </div>
<sidebar-subscription :sidebar-collapsed="sidebarCollapsed" /> <sidebar-subscription :sidebar-collapsed="sidebarCollapsed" data-testid="subscribe" />
</div> </div>
</aside> </aside>
</template> </template>
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EpicSidebar from 'ee/epic/components/epic_sidebar.vue'; import EpicSidebar from 'ee/epic/components/epic_sidebar.vue';
...@@ -7,28 +6,32 @@ import createStore from 'ee/epic/store'; ...@@ -7,28 +6,32 @@ import createStore from 'ee/epic/store';
import epicUtils from 'ee/epic/utils/epic_utils'; import epicUtils from 'ee/epic/utils/epic_utils';
import { dateTypes } from 'ee/epic/constants'; import { dateTypes } from 'ee/epic/constants';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { mockEpicMeta, mockEpicData, mockAncestors } from '../mock_data'; import { mockEpicMeta, mockEpicData, mockAncestors } from '../mock_data';
const createComponent = ({ methods } = {}) => {
const store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
store.state.ancestors = mockAncestors;
return shallowMount(EpicSidebar, {
store,
methods,
});
};
describe('EpicSidebarComponent', () => { describe('EpicSidebarComponent', () => {
const originalUserId = gon.current_user_id; const originalUserId = gon.current_user_id;
let vm; let wrapper;
let store;
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(EpicSidebar); wrapper = createComponent();
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
store.state.ancestors = mockAncestors;
vm = mountComponentWithStore(Component, {
store,
});
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('methods', () => { describe('methods', () => {
...@@ -36,7 +39,7 @@ describe('EpicSidebarComponent', () => { ...@@ -36,7 +39,7 @@ describe('EpicSidebarComponent', () => {
it('calls `epicUtils.getDateFromMilestonesTooltip` with `dateType` param', () => { it('calls `epicUtils.getDateFromMilestonesTooltip` with `dateType` param', () => {
jest.spyOn(epicUtils, 'getDateFromMilestonesTooltip'); jest.spyOn(epicUtils, 'getDateFromMilestonesTooltip');
vm.getDateFromMilestonesTooltip(dateTypes.start); wrapper.vm.getDateFromMilestonesTooltip(dateTypes.start);
expect(epicUtils.getDateFromMilestonesTooltip).toHaveBeenCalledWith( expect(epicUtils.getDateFromMilestonesTooltip).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
...@@ -48,11 +51,11 @@ describe('EpicSidebarComponent', () => { ...@@ -48,11 +51,11 @@ describe('EpicSidebarComponent', () => {
describe('changeStartDateType', () => { describe('changeStartDateType', () => {
it('calls `toggleStartDateType` on component with `dateTypeIsFixed` param', () => { it('calls `toggleStartDateType` on component with `dateTypeIsFixed` param', () => {
jest.spyOn(vm, 'toggleStartDateType'); jest.spyOn(wrapper.vm, 'toggleStartDateType');
vm.changeStartDateType(true, true); wrapper.vm.changeStartDateType(true, true);
expect(vm.toggleStartDateType).toHaveBeenCalledWith( expect(wrapper.vm.toggleStartDateType).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
}), }),
...@@ -60,11 +63,11 @@ describe('EpicSidebarComponent', () => { ...@@ -60,11 +63,11 @@ describe('EpicSidebarComponent', () => {
}); });
it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => { it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.changeStartDateType(true, false); wrapper.vm.changeStartDateType(true, false);
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.start, dateType: dateTypes.start,
...@@ -76,11 +79,11 @@ describe('EpicSidebarComponent', () => { ...@@ -76,11 +79,11 @@ describe('EpicSidebarComponent', () => {
describe('saveStartDate', () => { describe('saveStartDate', () => {
it('calls `saveDate` on component with `date` param set to `newDate`', () => { it('calls `saveDate` on component with `date` param set to `newDate`', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.saveStartDate('2018-1-1'); wrapper.vm.saveStartDate('2018-1-1');
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.start, dateType: dateTypes.start,
...@@ -92,11 +95,11 @@ describe('EpicSidebarComponent', () => { ...@@ -92,11 +95,11 @@ describe('EpicSidebarComponent', () => {
describe('changeDueDateType', () => { describe('changeDueDateType', () => {
it('calls `toggleDueDateType` on component with `dateTypeIsFixed` param', () => { it('calls `toggleDueDateType` on component with `dateTypeIsFixed` param', () => {
jest.spyOn(vm, 'toggleDueDateType'); jest.spyOn(wrapper.vm, 'toggleDueDateType');
vm.changeDueDateType(true, true); wrapper.vm.changeDueDateType(true, true);
expect(vm.toggleDueDateType).toHaveBeenCalledWith( expect(wrapper.vm.toggleDueDateType).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
}), }),
...@@ -104,11 +107,11 @@ describe('EpicSidebarComponent', () => { ...@@ -104,11 +107,11 @@ describe('EpicSidebarComponent', () => {
}); });
it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => { it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.changeDueDateType(true, false); wrapper.vm.changeDueDateType(true, false);
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.due, dateType: dateTypes.due,
...@@ -120,11 +123,11 @@ describe('EpicSidebarComponent', () => { ...@@ -120,11 +123,11 @@ describe('EpicSidebarComponent', () => {
describe('saveDueDate', () => { describe('saveDueDate', () => {
it('calls `saveDate` on component with `date` param set to `newDate`', () => { it('calls `saveDate` on component with `date` param set to `newDate`', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.saveDueDate('2018-1-1'); wrapper.vm.saveDueDate('2018-1-1');
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.due, dateType: dateTypes.due,
...@@ -144,117 +147,86 @@ describe('EpicSidebarComponent', () => { ...@@ -144,117 +147,86 @@ describe('EpicSidebarComponent', () => {
gon.current_user_id = originalUserId; gon.current_user_id = originalUserId;
}); });
it('renders component container element with classes `right-sidebar-expanded`, `right-sidebar` & `epic-sidebar`', done => { it('renders component container element with classes `right-sidebar-expanded`, `right-sidebar` & `epic-sidebar`', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
expect(vm.$el.classList.contains('right-sidebar-expanded')).toBe(true); expect(wrapper.classes()).toContain('right-sidebar-expanded');
expect(vm.$el.classList.contains('right-sidebar')).toBe(true); expect(wrapper.classes()).toContain('right-sidebar');
expect(vm.$el.classList.contains('epic-sidebar')).toBe(true); expect(wrapper.classes()).toContain('epic-sidebar');
})
.then(done)
.catch(done.fail);
}); });
it('renders header container element with classes `issuable-sidebar` & `js-issuable-update`', () => { it('renders header container element with classes `issuable-sidebar` & `js-issuable-update`', () => {
expect(vm.$el.querySelector('.issuable-sidebar.js-issuable-update')).not.toBeNull(); expect(wrapper.find('.issuable-sidebar.js-issuable-update').exists()).toBe(true);
}); });
it('renders Todo toggle button element when sidebar is collapsed and user is signed in', done => { it('renders Todo toggle button element when sidebar is collapsed and user is signed in', async () => {
store.dispatch('toggleSidebarFlag', true); wrapper.vm.$store.dispatch('toggleSidebarFlag', true);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
const todoBlockEl = vm.$el.querySelector('.block.todo');
expect(todoBlockEl).not.toBeNull(); expect(wrapper.find('[data-testid="todo"]').exists()).toBe(true);
expect(todoBlockEl.querySelector('button.btn-todo')).not.toBeNull();
})
.then(done)
.catch(done.fail);
}); });
it('renders Start date & Due date elements when sidebar is expanded', done => { it('renders Start date & Due date elements when sidebar is expanded', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
const startDateEl = vm.$el.querySelector('.block.date.start-date'); const startDateEl = wrapper.find('[data-testid="start-date"]');
const dueDateEl = vm.$el.querySelector('.block.date.due-date'); const dueDateEl = wrapper.find('[data-testid="due-date"]');
expect(startDateEl).not.toBeNull(); expect(startDateEl.exists()).toBe(true);
expect(startDateEl.querySelector('.title').innerText.trim()).toContain('Start date'); expect(startDateEl.props()).toMatchObject({
expect( label: 'Start date',
startDateEl.querySelector('.value .value-type-fixed .value-content').innerText.trim(), dateFixed: parsePikadayDate(mockEpicMeta.startDateFixed),
).toBe('Jun 1, 2018'); });
expect(dueDateEl).not.toBeNull(); expect(dueDateEl.exists()).toBe(true);
expect(dueDateEl.querySelector('.title').innerText.trim()).toContain('Due date'); expect(dueDateEl.props()).toMatchObject({
expect( label: 'Due date',
dueDateEl.querySelector('.value .value-type-fixed .value-content').innerText.trim(), dateFixed: parsePikadayDate(mockEpicMeta.dueDateFixed),
).toBe('Aug 1, 2018'); });
})
.then(done)
.catch(done.fail);
}); });
it('renders labels select element', () => { it('renders labels select element', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull(); expect(wrapper.find('[data-testid="labels-select"]').exists()).toBe(true);
}); });
describe('when sub-epics feature is available', () => { describe('when sub-epics feature is available', () => {
it('renders ancestors list', done => { it('renders ancestors list', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
store.dispatch('setEpicMeta', { wrapper.vm.$store.dispatch('setEpicMeta', {
...mockEpicMeta, ...mockEpicMeta,
allowSubEpics: false, allowSubEpics: false,
}); });
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
expect(vm.$el.querySelector('.block.ancestors')).toBeNull(); expect(wrapper.find('.block.ancestors').exists()).toBe(false);
})
.then(done)
.catch(done.fail);
}); });
}); });
describe('when sub-epics feature is not available', () => { describe('when sub-epics feature is not available', () => {
it('does not render ancestors list', done => { it('does not render ancestors list', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
const reverseAncestors = [...mockAncestors].reverse(); await wrapper.vm.$nextTick();
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector)); const ancestorsEl = wrapper.find('[data-testid="ancestors"]');
expect(ancestorsEl).not.toBeNull(); expect(ancestorsEl.exists()).toBe(true);
expect(ancestorsEl.props('ancestors')).toEqual([...mockAncestors].reverse());
expect(getEls('li.vertical-timeline-row')).toHaveLength(reverseAncestors.length);
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
}); });
}); });
it('renders participants list element', () => { it('renders participants list element', () => {
expect(vm.$el.querySelector('.block.participants')).not.toBeNull(); expect(wrapper.find('.block.participants').exists()).toBe(true);
}); });
it('renders subscription toggle element', () => { it('renders subscription toggle element', () => {
expect(vm.$el.querySelector('.block.subscription')).not.toBeNull(); expect(wrapper.find('[data-testid="subscribe"]').exists()).toBe(true);
}); });
}); });
...@@ -264,12 +236,13 @@ describe('EpicSidebarComponent', () => { ...@@ -264,12 +236,13 @@ describe('EpicSidebarComponent', () => {
fetchEpicDetails: jest.fn(), fetchEpicDetails: jest.fn(),
}; };
shallowMount(EpicSidebar, { const wrapperWithMethod = createComponent({
store,
methods: methodSpies, methods: methodSpies,
}); });
expect(methodSpies.fetchEpicDetails).toHaveBeenCalled(); expect(methodSpies.fetchEpicDetails).toHaveBeenCalled();
wrapperWithMethod.destroy();
}); });
}); });
}); });
...@@ -22,6 +22,7 @@ describe('SidebarLabelsComponent', () => { ...@@ -22,6 +22,7 @@ describe('SidebarLabelsComponent', () => {
propsData: { canUpdate: false, sidebarCollapsed: false }, propsData: { canUpdate: false, sidebarCollapsed: false },
store, store,
stubs: { stubs: {
LabelsSelectVue: true,
GlLabel: true, GlLabel: true,
}, },
}); });
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import {
GlIntersectionObserver,
GlButton,
GlLoadingIcon,
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
...@@ -88,20 +93,25 @@ describe('DropdownContentsLabelsView', () => { ...@@ -88,20 +93,25 @@ describe('DropdownContentsLabelsView', () => {
}); });
}); });
describe('showListContainer', () => { describe('showNoMatchingResultsMessage', () => {
it.each` it.each`
variant | loading | showList searchKey | labels | labelsDescription | returnValue
${'sidebar'} | ${false} | ${true} ${''} | ${[]} | ${'empty'} | ${false}
${'sidebar'} | ${true} | ${false} ${'bug'} | ${[]} | ${'empty'} | ${true}
${'not-sidebar'} | ${true} | ${true} ${''} | ${mockLabels} | ${'not empty'} | ${false}
${'not-sidebar'} | ${false} | ${true} ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
`( `(
'returns $showList if `state.variant` is "$variant" and `labelsFetchInProgress` is $loading', 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
({ variant, loading, showList }) => { async ({ searchKey, labels, returnValue }) => {
createComponent({ ...mockConfig, variant }); wrapper.setData({
wrapper.vm.$store.state.labelsFetchInProgress = loading; searchKey,
});
expect(wrapper.vm.showListContainer).toBe(showList); wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
await wrapper.vm.$nextTick();
expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
}, },
); );
}); });
...@@ -118,6 +128,28 @@ describe('DropdownContentsLabelsView', () => { ...@@ -118,6 +128,28 @@ describe('DropdownContentsLabelsView', () => {
}); });
}); });
describe('handleComponentDisappear', () => {
it('calls action `receiveLabelsSuccess` with empty array', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
wrapper.vm.handleComponentDisappear();
expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
});
});
describe('handleCreateLabelClick', () => {
it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
wrapper.vm.handleCreateLabelClick();
expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
});
});
describe('handleKeyDown', () => { describe('handleKeyDown', () => {
it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
wrapper.setData({ wrapper.setData({
...@@ -226,8 +258,8 @@ describe('DropdownContentsLabelsView', () => { ...@@ -226,8 +258,8 @@ describe('DropdownContentsLabelsView', () => {
}); });
describe('template', () => { describe('template', () => {
it('renders component container element with class `labels-select-contents-list`', () => { it('renders gl-intersection-observer as component root', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-list'); expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
}); });
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => { it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
...@@ -272,15 +304,11 @@ describe('DropdownContentsLabelsView', () => { ...@@ -272,15 +304,11 @@ describe('DropdownContentsLabelsView', () => {
expect(searchInputEl.attributes('autofocus')).toBe('true'); expect(searchInputEl.attributes('autofocus')).toBe('true');
}); });
it('renders smart-virtual-list element', () => {
expect(wrapper.find(SmartVirtualList).exists()).toBe(true);
});
it('renders label elements for all labels', () => { it('renders label elements for all labels', () => {
expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
}); });
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
wrapper.setData({ wrapper.setData({
currentHighlightItem: 0, currentHighlightItem: 0,
}); });
...@@ -288,7 +316,7 @@ describe('DropdownContentsLabelsView', () => { ...@@ -288,7 +316,7 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const labelItemEl = findDropdownContent().find(LabelItem); const labelItemEl = findDropdownContent().find(LabelItem);
expect(labelItemEl.props('highlight')).toBe(true); expect(labelItemEl.attributes('highlight')).toBe('true');
}); });
}); });
...@@ -310,9 +338,12 @@ describe('DropdownContentsLabelsView', () => { ...@@ -310,9 +338,12 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const dropdownContent = findDropdownContent(); const dropdownContent = findDropdownContent();
const loadingIcon = findLoadingIcon();
expect(dropdownContent.exists()).toBe(true); expect(dropdownContent.exists()).toBe(true);
expect(dropdownContent.isVisible()).toBe(false); expect(dropdownContent.isVisible()).toBe(true);
expect(loadingIcon.exists()).toBe(true);
expect(loadingIcon.isVisible()).toBe(true);
}); });
}); });
......
...@@ -6,11 +6,15 @@ import { mockRegularLabel } from './mock_data'; ...@@ -6,11 +6,15 @@ import { mockRegularLabel } from './mock_data';
const mockLabel = { ...mockRegularLabel, set: true }; const mockLabel = { ...mockRegularLabel, set: true };
const createComponent = ({ label = mockLabel, highlight = true } = {}) => const createComponent = ({
label = mockLabel,
isLabelSet = mockLabel.set,
highlight = true,
} = {}) =>
shallowMount(LabelItem, { shallowMount(LabelItem, {
propsData: { propsData: {
label, label,
isLabelSet: label.set, isLabelSet,
highlight, highlight,
}, },
}); });
...@@ -26,94 +30,44 @@ describe('LabelItem', () => { ...@@ -26,94 +30,44 @@ describe('LabelItem', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => {
describe('labelBoxStyle', () => {
it('returns an object containing `backgroundColor` based on `label` prop', () => {
expect(wrapper.vm.labelBoxStyle).toEqual(
expect.objectContaining({
backgroundColor: mockLabel.color,
}),
);
});
});
});
describe('watchers', () => {
describe('isLabelSet', () => {
it('sets value of `isLabelSet` to `isSet` data prop', () => {
expect(wrapper.vm.isSet).toBe(true);
wrapper.setProps({
isLabelSet: false,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isSet).toBe(false);
});
});
});
});
describe('methods', () => {
describe('handleClick', () => {
it('sets value of `isSet` data prop to opposite of its current value', () => {
wrapper.setData({
isSet: true,
});
wrapper.vm.handleClick();
expect(wrapper.vm.isSet).toBe(false);
wrapper.vm.handleClick();
expect(wrapper.vm.isSet).toBe(true);
});
it('emits event `clickLabel` on component with `label` prop as param', () => {
wrapper.vm.handleClick();
expect(wrapper.emitted('clickLabel')).toBeTruthy();
expect(wrapper.emitted('clickLabel')[0]).toEqual([mockLabel]);
});
});
});
describe('template', () => { describe('template', () => {
it('renders gl-link component', () => { it('renders gl-link component', () => {
expect(wrapper.find(GlLink).exists()).toBe(true); expect(wrapper.find(GlLink).exists()).toBe(true);
}); });
it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { it('renders component root with class `is-focused` when `highlight` prop is true', () => {
wrapper.setProps({ const wrapperTemp = createComponent({
highlight: true, highlight: true,
}); });
return wrapper.vm.$nextTick(() => { expect(wrapperTemp.classes()).toContain('is-focused');
expect(wrapper.find(GlLink).classes()).toContain('is-focused');
}); wrapperTemp.destroy();
}); });
it('renders visible gl-icon component when `isSet` prop is true', () => { it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
wrapper.setData({ const wrapperTemp = createComponent({
isSet: true, isLabelSet: true,
}); });
return wrapper.vm.$nextTick(() => { const iconEl = wrapperTemp.find(GlIcon);
const iconEl = wrapper.find(GlIcon);
expect(iconEl.isVisible()).toBe(true); expect(iconEl.isVisible()).toBe(true);
expect(iconEl.props('name')).toBe('mobile-issue-close'); expect(iconEl.props('name')).toBe('mobile-issue-close');
});
wrapperTemp.destroy();
}); });
it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
wrapper.setData({ const wrapperTemp = createComponent({
isSet: false, isLabelSet: false,
}); });
return wrapper.vm.$nextTick(() => { const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
const placeholderEl = wrapper.find('[data-testid="no-icon"]');
expect(placeholderEl.isVisible()).toBe(true); expect(placeholderEl.isVisible()).toBe(true);
});
wrapperTemp.destroy();
}); });
it('renders label color element', () => { it('renders label color element', () => {
......
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