Commit d0d366ca authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '292104-epic-select-migration' into 'master'

Feat(EpicSelect): Migrate epic select

See merge request gitlab-org/gitlab!52528
parents 6b7dc1fa 73056f3c
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import {
import $ from 'jquery'; GlLink,
import { GlLoadingIcon } from '@gitlab/ui'; GlLoadingIcon,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { noneEpic } from 'ee/vue_shared/constants'; import { noneEpic } from 'ee/vue_shared/constants';
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
import createStore from './store'; import createStore from './store';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue'; import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import { DropdownVariant, DATA_REFETCH_DELAY } from './constants';
import DropdownButton from './dropdown_button.vue'; export const i18n = {
import DropdownHeader from './dropdown_header.vue'; selectEpic: s__('Epics|Select epic'),
import DropdownSearchInput from './dropdown_search_input.vue'; searchEpic: s__('Epics|Search epics'),
import DropdownContents from './dropdown_contents.vue'; assignEpic: s__('Epics|Assign Epic'),
noMatch: __('No Matching Results'),
import { DropdownVariant } from './constants'; };
export default { export default {
i18n,
noneEpic,
store: createStore(), store: createStore(),
components: { components: {
GlLink,
GlLoadingIcon, GlLoadingIcon,
DropdownTitle, GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByType,
DropdownValue, DropdownValue,
DropdownValueCollapsed, DropdownValueCollapsed,
DropdownButton,
DropdownHeader,
DropdownSearchInput,
DropdownContents,
}, },
props: { props: {
groupId: { groupId: {
...@@ -51,11 +56,6 @@ export default { ...@@ -51,11 +56,6 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
blockTitle: {
type: String,
required: false,
default: __('Epic'),
},
initialEpic: { initialEpic: {
type: Object, type: Object,
required: true, required: true,
...@@ -72,12 +72,13 @@ export default { ...@@ -72,12 +72,13 @@ export default {
showHeader: { showHeader: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: true,
}, },
}, },
data() { data() {
return { return {
showDropdown: this.variant === DropdownVariant.Standalone, isDropdownShowing: false,
search: '',
}; };
}, },
computed: { computed: {
...@@ -89,11 +90,37 @@ export default { ...@@ -89,11 +90,37 @@ export default {
'selectedEpicIssueId', 'selectedEpicIssueId',
]), ]),
...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone', 'groupEpics']), ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone', 'groupEpics']),
dropdownSelectInProgress() {
return this.initialEpicLoading || this.epicSelectInProgress;
},
dropdownButtonTextClass() { dropdownButtonTextClass() {
return { 'is-default': this.isDropdownVariantStandalone }; return {
'is-default': this.isDropdownVariantStandalone,
'dropdown-menu-toggle js-epic-select js-extra-options gl-py-3!': true,
};
},
dropDownTitle() {
return this.selectedEpic.title || this.$options.i18n.selectEpic;
},
dropdownClass() {
if (this.isDropdownVariantSidebar) {
return this.isDropdownShowing ? 'dropdown-menu-epics' : 'gl-display-none';
}
return 'dropdown-menu-epics';
},
dropdownHeaderText() {
if (this.showHeader) {
return this.$options.i18n.assignEpic;
}
return '';
},
isLoading() {
return this.epicsFetchInProgress || this.epicSelectInProgress || this.initialEpicLoading;
},
epicListValid() {
return this.groupEpics.length > 0 && !this.isLoading;
},
epicListNotValid() {
return this.groupEpics.length === 0 && !this.isLoading;
}, },
}, },
watch: { watch: {
...@@ -133,6 +160,9 @@ export default { ...@@ -133,6 +160,9 @@ export default {
this.fetchEpics(); this.fetchEpics();
} }
}, },
search: debounce(function debouncedEpicSearch() {
this.setSearchQuery(this.search);
}, DATA_REFETCH_DELAY),
}, },
mounted() { mounted() {
this.setInitialData({ this.setInitialData({
...@@ -142,8 +172,6 @@ export default { ...@@ -142,8 +172,6 @@ export default {
selectedEpic: this.initialEpic, selectedEpic: this.initialEpic,
selectedEpicIssueId: this.epicIssueId, selectedEpicIssueId: this.epicIssueId,
}); });
$(this.$refs.dropdown).on('shown.bs.dropdown', () => this.fetchEpics());
$(this.$refs.dropdown).on('hidden.bs.dropdown', this.handleDropdownHidden);
}, },
methods: { methods: {
...mapActions([ ...mapActions([
...@@ -156,26 +184,12 @@ export default { ...@@ -156,26 +184,12 @@ export default {
'assignIssueToEpic', 'assignIssueToEpic',
'removeIssueFromEpic', 'removeIssueFromEpic',
]), ]),
handleEditClick() {
this.showDropdown = true;
// Wait for component to render dropdown container
this.$nextTick(() => {
// We're not calling $.dropdown('show') to open
// dropdown and instead triggerring click on button
// so that clicking outside can make dropdown close
// additionally, this approach requires event trigger
// to be deferred so that it doesn't close
setTimeout(() => {
$(this.$refs.dropdownButton.$el).trigger('click');
});
});
},
handleDropdownHidden() {
this.showDropdown = this.isDropdownVariantStandalone;
},
handleItemSelect(epic) { handleItemSelect(epic) {
if (this.selectedEpicIssueId && epic.id === noneEpic.id && epic.title === noneEpic.title) { if (
this.selectedEpicIssueId &&
epic.id === this.$options.noneEpic.id &&
epic.title === this.$options.noneEpic.title
) {
this.removeIssueFromEpic(this.selectedEpic); this.removeIssueFromEpic(this.selectedEpic);
} else if (this.issueId) { } else if (this.issueId) {
this.assignIssueToEpic(epic); this.assignIssueToEpic(epic);
...@@ -183,50 +197,95 @@ export default { ...@@ -183,50 +197,95 @@ export default {
this.$emit('epicSelect', epic); this.$emit('epicSelect', epic);
} }
}, },
hideDropdown() {
this.isDropdownShowing = this.isDropdownVariantStandalone;
},
toggleFormDropdown() {
const { dropdown } = this.$refs.dropdown.$refs;
this.isDropdownShowing = !this.isDropdownShowing;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
this.fetchEpics();
}
},
}, },
}; };
</script> </script>
<template> <template>
<div class="js-epic-block" :class="{ 'block epic': isDropdownVariantSidebar }"> <div class="js-epic-block" :class="{ 'block epic': isDropdownVariantSidebar }">
<dropdown-value-collapsed v-if="isDropdownVariantSidebar" :epic="selectedEpic" /> <div class="hide-collapsed epic-dropdown-container">
<dropdown-title <p
v-if="isDropdownVariantSidebar" v-if="isDropdownVariantSidebar"
:can-edit="canEdit" class="title gl-display-flex gl-justify-content-space-between"
:block-title="blockTitle" >
:is-loading="dropdownSelectInProgress" <span>
@onClickEdit="handleEditClick" {{ __('Epic')
/> }}<gl-loading-icon v-if="epicSelectInProgress" class="gl-ml-2" :inline="true"
<dropdown-value v-if="isDropdownVariantSidebar" v-show="!showDropdown" :epic="selectedEpic"> /></span>
<slot></slot>
</dropdown-value> <gl-link
<div v-if="canEdit"
v-if="canEdit || isDropdownVariantStandalone" ref="editButton"
v-show="showDropdown" class="sidebar-dropdown-toggle"
class="epic-dropdown-container" href="#"
> @click="toggleFormDropdown"
<div ref="dropdown" class="dropdown"> @keydown.esc="hideDropdown"
<dropdown-button >
ref="dropdownButton" {{ __('Edit') }}
:selected-epic-title="selectedEpic.title" </gl-link>
:toggle-text-class="dropdownButtonTextClass" </p>
/>
<div class="dropdown-menu dropdown-select dropdown-menu-epics dropdown-menu-selectable"> <gl-dropdown
<dropdown-header v-if="isDropdownVariantSidebar || showHeader" /> v-if="canEdit || isDropdownVariantStandalone"
<dropdown-search-input @onSearchInput="setSearchQuery" /> ref="dropdown"
<dropdown-contents :text="dropDownTitle"
v-if="!epicsFetchInProgress" class="gl-w-full"
:epics="groupEpics" :class="dropdownClass"
:selected-epic="selectedEpic" :toggle-class="dropdownButtonTextClass"
@onItemSelect="handleItemSelect" :header-text="dropdownHeaderText"
/> @keydown.esc.native="hideDropdown"
<gl-loading-icon @hide="hideDropdown"
v-if="epicsFetchInProgress" @toggle="toggleFormDropdown"
class="dropdown-contents-loading" >
size="md" <template #header>
/> <gl-search-box-by-type v-model.trim="search" :placeholder="$options.i18n.searchEpic" />
</div> </template>
</div> <template v-if="epicListValid">
<gl-dropdown-item
:active="!selectedEpic"
active-class="is-active"
:is-check-item="true"
:is-checked="selectedEpic.id === $options.noneEpic.id"
@click="handleItemSelect($options.noneEpic)"
>
{{ __('No Epic') }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="epic in groupEpics"
:key="epic.id"
:active="selectedEpic.id === epic.id"
active-class="is-active"
:is-check-item="true"
:is-checked="selectedEpic.id === epic.id"
@click="handleItemSelect(epic)"
>{{ epic.title }}</gl-dropdown-item
>
</template>
<p v-else-if="epicListNotValid" class="gl-mx-5 gl-my-4">
{{ $options.i18n.noMatch }}
</p>
<gl-loading-icon v-else />
</gl-dropdown>
</div>
<div v-if="isDropdownVariantSidebar && !isDropdownShowing">
<dropdown-value-collapsed :epic="selectedEpic" />
<dropdown-value :epic="selectedEpic">
<slot></slot>
</dropdown-value>
</div> </div>
</div> </div>
</template> </template>
...@@ -2,3 +2,5 @@ export const DropdownVariant = { ...@@ -2,3 +2,5 @@ export const DropdownVariant = {
Sidebar: 'sidebar', Sidebar: 'sidebar',
Standalone: 'standalone', Standalone: 'standalone',
}; };
export const DATA_REFETCH_DELAY = 250;
<script>
import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
},
props: {
selectedEpicTitle: {
type: String,
required: false,
default: '',
},
toggleTextClass: {
type: Object,
required: false,
default: null,
},
},
computed: {
buttonText() {
return this.selectedEpicTitle || __('Epic');
},
},
};
</script>
<template>
<button
type="button"
class="dropdown-menu-toggle js-epic-select js-extra-options gl-w-full"
data-display="static"
data-toggle="dropdown"
>
<span class="dropdown-toggle-text" :class="toggleTextClass">{{ buttonText }}</span>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</button>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import { noneEpic } from 'ee/vue_shared/constants';
export default {
noneEpic,
components: {
GlLink,
},
props: {
epics: {
type: Array,
required: true,
},
selectedEpic: {
type: Object,
required: false,
default: () => null,
},
},
computed: {
isNoEpic() {
return (
this.selectedEpic.id === this.$options.noneEpic.id &&
this.selectedEpic.title === this.$options.noneEpic.title
);
},
},
methods: {
isSelected(epic) {
return this.selectedEpic.id === epic.id;
},
handleItemClick(epic) {
if (epic.id !== this.selectedEpic.id) {
this.$emit('onItemSelect', epic);
} else if (epic.id !== noneEpic.id) {
this.$emit('onItemSelect', noneEpic);
}
},
},
};
</script>
<template>
<div class="dropdown-content">
<ul>
<li data-epic-id="None">
<gl-link
:class="{ 'is-active': isNoEpic }"
@click.prevent="handleItemClick($options.noneEpic)"
>{{ __('No Epic') }}</gl-link
>
</li>
<li class="divider"></li>
<template v-if="epics.length">
<li v-for="epic in epics" :key="epic.id">
<gl-link
:class="{ 'is-active': isSelected(epic) }"
@click.prevent="handleItemClick(epic)"
>{{ epic.title }}</gl-link
>
</li>
</template>
<li v-else class="d-block text-center p-2">{{ __('No matches found') }}</li>
</ul>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
export default {
components: {
GlButton,
},
};
</script>
<template>
<div class="dropdown-title gl-display-flex gl-align-items-center">
<span class="gl-ml-auto">{{ __('Assign epic') }}</span>
<gl-button
:aria-label="__('Close')"
category="tertiary"
class="dropdown-title-button dropdown-menu-close gl-ml-auto"
size="small"
icon="close"
/>
</div>
</template>
<script>
import { debounce } from 'lodash';
import { GlButton, GlIcon } from '@gitlab/ui';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
components: {
GlButton,
GlIcon,
},
directives: {
autofocusonshow,
},
data() {
return {
query: '',
};
},
methods: {
handleKeyUp: debounce(function debouncedKeyUp() {
this.$emit('onSearchInput', this.query);
}, 300),
handleInputClear() {
this.query = '';
this.handleKeyUp();
},
},
};
</script>
<template>
<div :class="{ 'has-value': query }" class="dropdown-input">
<input
v-model.trim="query"
v-autofocusonshow
:placeholder="__('Search')"
autocomplete="off"
class="dropdown-input-field"
type="search"
@keyup="handleKeyUp"
/>
<gl-icon v-show="!query" class="dropdown-input-search" name="search" />
<gl-button
variant="link"
icon="close"
class="dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
@click.stop="handleInputClear"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
GlLink,
},
props: {
canEdit: {
type: Boolean,
required: true,
},
blockTitle: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="title hide-collapsed align-items-center gl-mb-3">
<div class="flex-grow-1">
<span :class="{ 'align-text-top': isLoading }">{{ blockTitle }}</span>
<gl-loading-icon v-show="isLoading" inline />
</div>
<template v-if="canEdit">
<gl-link
class="edit-link float-right sidebar-dropdown-toggle"
@click="$emit('onClickEdit', $event)"
>{{ __('Edit') }}</gl-link
>
</template>
</div>
</template>
...@@ -10,4 +10,4 @@ ...@@ -10,4 +10,4 @@
.col-md-10.col-lg-8 .col-md-10.col-lg-8
.issuable-form-select-holder .issuable-form-select-holder
%input{ id: 'issue_epic_id', type: 'hidden', name: 'issue[epic_id]' } %input{ id: 'issue_epic_id', type: 'hidden', name: 'issue[epic_id]' }
#js-epic-select-root{ data: { group_id: project.group.id } } #js-epic-select-root{ data: { group_id: project.group.id, show_header: 'true' } }
---
title: 'Feat(EpicSelect): Migrate epic select to internal GitLab UI compoenent'
merge_request: 52528
author:
type: changed
...@@ -31,7 +31,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -31,7 +31,7 @@ RSpec.describe 'Issue Boards', :js do
project.add_maintainer(user) project.add_maintainer(user)
project.team.add_developer(user2) project.team.add_developer(user2)
gitlab_sign_in(user) sign_in user
visit project_board_path(project, board) visit project_board_path(project, board)
wait_for_requests wait_for_requests
...@@ -152,6 +152,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -152,6 +152,7 @@ RSpec.describe 'Issue Boards', :js do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
group.add_owner(user) group.add_owner(user)
visit project_board_path(project, board) visit project_board_path(project, board)
wait_for_requests wait_for_requests
end end
...@@ -185,7 +186,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -185,7 +186,7 @@ RSpec.describe 'Issue Boards', :js do
page.find('.sidebar-dropdown-toggle').click page.find('.sidebar-dropdown-toggle').click
wait_for_requests wait_for_requests
click_link epic2.title find('.gl-new-dropdown-item', text: epic2.title).click
wait_for_requests wait_for_requests
expect(page.find('.value')).to have_content(epic2.title) expect(page.find('.value')).to have_content(epic2.title)
......
...@@ -93,7 +93,7 @@ RSpec.describe 'Issues > Epic bulk assignment', :js do ...@@ -93,7 +93,7 @@ RSpec.describe 'Issues > Epic bulk assignment', :js do
page.within('.issues-bulk-update') do page.within('.issues-bulk-update') do
click_button 'Select epic' click_button 'Select epic'
items.map do |item| items.map do |item|
find('.gl-link', text: item).click find('.gl-new-dropdown-item', text: item).click
end end
end end
end end
......
...@@ -16,6 +16,7 @@ RSpec.describe 'Epic in issue sidebar', :js do ...@@ -16,6 +16,7 @@ RSpec.describe 'Epic in issue sidebar', :js do
context 'projects within a group' do context 'projects within a group' do
before do before do
group.add_owner(user) group.add_owner(user)
sign_in user
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end end
...@@ -34,7 +35,7 @@ RSpec.describe 'Epic in issue sidebar', :js do ...@@ -34,7 +35,7 @@ RSpec.describe 'Epic in issue sidebar', :js do
wait_for_requests wait_for_requests
expect(page).to have_selector('.js-epic-select', visible: true) expect(page).to have_selector('.js-epic-select', visible: true)
expect(page.all('.dropdown-content li a').length).to eq(4) # `No Epic` + 3 epics expect(page.all('.gl-new-dropdown-contents .gl-new-dropdown-item').length).to eq(4) # `No Epic` + 3 epics
end end
end end
...@@ -44,11 +45,11 @@ RSpec.describe 'Epic in issue sidebar', :js do ...@@ -44,11 +45,11 @@ RSpec.describe 'Epic in issue sidebar', :js do
wait_for_requests wait_for_requests
page.find('.dropdown-input-field').send_keys('Foo') page.find('.gl-form-input').send_keys('Foo')
wait_for_requests wait_for_requests
expect(page).to have_selector('.dropdown-content li a', count: 2) # `No Epic` + 1 matching epic expect(page).to have_selector('.gl-new-dropdown-contents .gl-new-dropdown-item', count: 2) # `No Epic` + 1 matching epic
end end
end end
...@@ -58,7 +59,7 @@ RSpec.describe 'Epic in issue sidebar', :js do ...@@ -58,7 +59,7 @@ RSpec.describe 'Epic in issue sidebar', :js do
wait_for_requests wait_for_requests
click_link epic2.title find('.gl-new-dropdown-item', text: epic2.title).click
wait_for_requests wait_for_requests
......
...@@ -47,7 +47,7 @@ RSpec.describe "User creates issue", :js do ...@@ -47,7 +47,7 @@ RSpec.describe "User creates issue", :js do
it 'creates an issue with no epic' do it 'creates an issue with no epic' do
click_button 'Select epic' click_button 'Select epic'
click_link('No Epic') find('.gl-new-dropdown-item', text: 'No Epic').click
click_button('Submit issue') click_button('Submit issue')
wait_for_all_requests wait_for_all_requests
...@@ -61,7 +61,7 @@ RSpec.describe "User creates issue", :js do ...@@ -61,7 +61,7 @@ RSpec.describe "User creates issue", :js do
it 'credates an issue with an epic' do it 'credates an issue with an epic' do
click_button 'Select epic' click_button 'Select epic'
click_link(epic.title) find('.gl-new-dropdown-item', text: epic.title).click
click_button('Submit issue') click_button('Submit issue')
wait_for_all_requests wait_for_all_requests
......
...@@ -97,7 +97,6 @@ describe('SidebarItemEpicsSelect', () => { ...@@ -97,7 +97,6 @@ describe('SidebarItemEpicsSelect', () => {
describe('template', () => { describe('template', () => {
it('should render epics-select component', () => { it('should render epics-select component', () => {
expect(wrapper.find(EpicsSelect).element).toBe(wrapper.element); expect(wrapper.find(EpicsSelect).element).toBe(wrapper.element);
expect(wrapper.attributes('blocktitle')).toBe('Epic');
expect(wrapper.text()).toBe('None'); expect(wrapper.text()).toBe('None');
}); });
}); });
......
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { DropdownVariant } from 'ee/vue_shared/components/sidebar/epics_select//constants'; import { DropdownVariant } from 'ee/vue_shared/components/sidebar/epics_select//constants';
import EpicsSelectBase from 'ee/vue_shared/components/sidebar/epics_select/base.vue'; import EpicsSelectBase from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import DropdownButton from 'ee/vue_shared/components/sidebar/epics_select/dropdown_button.vue';
import DropdownContents from 'ee/vue_shared/components/sidebar/epics_select/dropdown_contents.vue';
import DropdownHeader from 'ee/vue_shared/components/sidebar/epics_select/dropdown_header.vue';
import DropdownSearchInput from 'ee/vue_shared/components/sidebar/epics_select/dropdown_search_input.vue';
import DropdownTitle from 'ee/vue_shared/components/sidebar/epics_select/dropdown_title.vue';
import DropdownValue from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value.vue'; import DropdownValue from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value.vue';
import DropdownValueCollapsed from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value_collapsed.vue'; import DropdownValueCollapsed from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value_collapsed.vue';
...@@ -19,7 +14,6 @@ describe('EpicsSelect', () => { ...@@ -19,7 +14,6 @@ describe('EpicsSelect', () => {
describe('Base', () => { describe('Base', () => {
let wrapper; let wrapper;
let wrapperStandalone; let wrapperStandalone;
// const errorMessage = 'Something went wrong while fetching group epics.';
const store = createDefaultStore(); const store = createDefaultStore();
const storeStandalone = createDefaultStore(); const storeStandalone = createDefaultStore();
...@@ -77,38 +71,35 @@ describe('EpicsSelect', () => { ...@@ -77,38 +71,35 @@ describe('EpicsSelect', () => {
describe('watchers', () => { describe('watchers', () => {
describe('issueId', () => { describe('issueId', () => {
it('should update `issueId` within state when prop is updated', () => { it('should update `issueId` within state when prop is updated', async () => {
wrapper.setProps({ wrapper.setProps({
issueId: 123, issueId: 123,
}); });
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.$store.state.issueId).toBe(123); expect(wrapper.vm.$store.state.issueId).toBe(123);
});
}); });
}); });
describe('initialEpic', () => { describe('initialEpic', () => {
it('should update `selectedEpic` within state when prop is updated', () => { it('should update `selectedEpic` within state when prop is updated', async () => {
wrapper.setProps({ wrapper.setProps({
initialEpic: mockEpic2, initialEpic: mockEpic2,
}); });
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2); expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
});
}); });
}); });
describe('initialEpicLoading', () => { describe('initialEpicLoading', () => {
it('should update `selectedEpic` within state when prop is updated', () => { it('should update `selectedEpic` within state when prop is updated', async () => {
wrapper.setProps({ wrapper.setProps({
initialEpic: mockEpic2, initialEpic: mockEpic2,
}); });
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2); expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
});
}); });
}); });
...@@ -117,36 +108,34 @@ describe('EpicsSelect', () => { ...@@ -117,36 +108,34 @@ describe('EpicsSelect', () => {
jest.spyOn(wrapper.vm, 'fetchEpics').mockImplementation(jest.fn()); jest.spyOn(wrapper.vm, 'fetchEpics').mockImplementation(jest.fn());
}); });
it('should call action `fetchEpics` with `searchQuery` when value is set and `groupEpics` is empty', () => { it('should call action `fetchEpics` with `searchQuery` when value is set and `groupEpics` is empty', async () => {
wrapper.vm.$store.dispatch('setSearchQuery', 'foo'); wrapper.vm.$store.dispatch('setSearchQuery', 'foo');
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo'); expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo');
});
}); });
it('should call action `fetchEpics` without any params when value is empty', () => { it('should call action `fetchEpics` without any params when value is empty', async () => {
wrapper.vm.$store.dispatch('setSearchQuery', ''); wrapper.vm.$store.dispatch('setSearchQuery', '');
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith(); expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith();
});
}); });
}); });
}); });
describe('methods', () => { describe('methods', () => {
describe('handleDropdownHidden', () => { describe('hideDropdown', () => {
it('should set `showDropdown` to false', () => { it('should set `isDropdownShowing` to false', () => {
wrapper.vm.handleDropdownHidden(); wrapper.vm.hideDropdown();
expect(wrapper.vm.showDropdown).toBe(false); expect(wrapper.vm.isDropdownShowing).toBe(false);
}); });
it('should set `showDropdown` to true when dropdown variant is "standalone"', () => { it('should set `isDropdownShowing` to true when dropdown variant is "standalone"', () => {
wrapperStandalone.vm.handleDropdownHidden(); wrapperStandalone.vm.hideDropdown();
expect(wrapperStandalone.vm.showDropdown).toBe(true); expect(wrapperStandalone.vm.isDropdownShowing).toBe(true);
}); });
}); });
...@@ -176,18 +165,17 @@ describe('EpicsSelect', () => { ...@@ -176,18 +165,17 @@ describe('EpicsSelect', () => {
expect(wrapper.vm.assignIssueToEpic).toHaveBeenCalledWith(mockEpic2); expect(wrapper.vm.assignIssueToEpic).toHaveBeenCalledWith(mockEpic2);
}); });
it('should emit component event `epicSelect` with both `epicIssueId` & `issueId` props are not defined', () => { it('should emit component event `epicSelect` with both `epicIssueId` & `issueId` props are not defined', async () => {
wrapperStandalone.setProps({ wrapperStandalone.setProps({
issueId: 0, issueId: 0,
epicIssueId: 0, epicIssueId: 0,
}); });
return wrapperStandalone.vm.$nextTick(() => { await wrapperStandalone.vm.$nextTick();
wrapperStandalone.vm.handleItemSelect(mockEpic2); wrapperStandalone.vm.handleItemSelect(mockEpic2);
expect(wrapperStandalone.emitted('epicSelect')).toBeTruthy(); expect(wrapperStandalone.emitted('epicSelect')).toBeTruthy();
expect(wrapperStandalone.emitted('epicSelect')[0]).toEqual([mockEpic2]); expect(wrapperStandalone.emitted('epicSelect')[0]).toEqual([mockEpic2]);
});
}); });
}); });
}); });
...@@ -198,7 +186,7 @@ describe('EpicsSelect', () => { ...@@ -198,7 +186,7 @@ describe('EpicsSelect', () => {
canEdit: true, canEdit: true,
}); });
w.setData({ w.setData({
showDropdown: true, isDropdownShowing: true,
}); });
}; };
...@@ -216,103 +204,71 @@ describe('EpicsSelect', () => { ...@@ -216,103 +204,71 @@ describe('EpicsSelect', () => {
expect(wrapperStandalone.find(DropdownValueCollapsed).exists()).toBe(false); expect(wrapperStandalone.find(DropdownValueCollapsed).exists()).toBe(false);
}); });
it('should render DropdownTitle component', () => { it('should render a dropdown title component', () => {
expect(wrapper.find(DropdownTitle).exists()).toBe(true); expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
}); });
it('should not render DropdownTitle component when variant is "standalone"', () => { it('should not render a dropdown title component when variant is "standalone"', () => {
expect(wrapperStandalone.find(DropdownTitle).exists()).toBe(false); expect(wrapperStandalone.findComponent(GlDropdown).find('.title').exists()).toBe(false);
}); });
it('should render DropdownValue component when `showDropdown` is false', (done) => { it('should render DropdownValue component when `showDropdown` is false', async () => {
wrapper.vm.showDropdown = false; wrapper.vm.showDropdown = false;
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(DropdownValue).exists()).toBe(true); expect(wrapper.find(DropdownValue).exists()).toBe(true);
done();
});
}); });
it('should not render DropdownValue component when variant is "standalone"', () => { it('should not render DropdownValue component when variant is "standalone"', () => {
expect(wrapperStandalone.find(DropdownValue).exists()).toBe(false); expect(wrapperStandalone.find(DropdownValue).exists()).toBe(false);
}); });
it('should render dropdown container element when props `canEdit` & `showDropdown` are true', (done) => { it('should render dropdown container element when props `canEdit` & `showDropdown` are true', async () => {
showDropdown(); showDropdown();
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find('.epic-dropdown-container').exists()).toBe(true); expect(wrapper.find('.epic-dropdown-container').exists()).toBe(true);
expect(wrapper.find('.epic-dropdown-container .dropdown').exists()).toBe(true); expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
done();
});
}); });
it('should render dropdown container element when variant is "standalone"', () => { it('should render dropdown container element when variant is "standalone"', () => {
expect(wrapperStandalone.find('.epic-dropdown-container').exists()).toBe(true); expect(wrapperStandalone.find('.epic-dropdown-container').exists()).toBe(true);
}); });
it('should render DropdownButton component when props `canEdit` & `showDropdown` are true', (done) => { it('should render dropdown menu container element when props `canEdit` & `showDropdown` are true', async () => {
showDropdown();
wrapper.vm.$nextTick(() => {
expect(wrapper.find(DropdownButton).exists()).toBe(true);
done();
});
});
it('should render dropdown menu container element when props `canEdit` & `showDropdown` are true', (done) => {
showDropdown(); showDropdown();
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find('.dropdown .dropdown-menu.dropdown-menu-epics').exists()).toBe(true); expect(wrapper.find('.dropdown-menu-epics').exists()).toBe(true);
done();
});
}); });
it('should render DropdownHeader component when props `canEdit` & `showDropdown` are true', (done) => { it('should render a dropdown header component when props `canEdit` & `showDropdown` are true', async () => {
showDropdown(); showDropdown();
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(DropdownHeader).exists()).toBe(true); expect(wrapper.findComponent(GlDropdown).props('headerText')).toBe('Assign Epic');
done();
});
}); });
it('should not render DropdownHeader component when variant is "standalone"', () => { it('should render a dropdown header component when variant is "standalone"', async () => {
showDropdown(wrapperStandalone); showDropdown(wrapperStandalone);
await wrapperStandalone.vm.$nextTick();
return wrapperStandalone.vm.$nextTick(() => { expect(wrapper.findComponent(GlDropdown).props('headerText')).toBe('Assign Epic');
expect(wrapperStandalone.find(DropdownHeader).exists()).toBe(false);
});
});
it('should render DropdownSearchInput component when props `canEdit` & `showDropdown` are true', (done) => {
showDropdown();
wrapper.vm.$nextTick(() => {
expect(wrapper.find(DropdownSearchInput).exists()).toBe(true);
done();
});
}); });
it('should render DropdownContents component when props `canEdit` & `showDropdown` are true and `isEpicsLoading` is false', (done) => { it('should render a list of dropdown items when props `canEdit` & `showDropdown` are true and `isEpicsLoading` is false and `receiveEpicsSuccess` returns a valid response of epics', async () => {
showDropdown(); showDropdown();
store.dispatch('receiveEpicsSuccess', []); store.dispatch('receiveEpicsSuccess', [mockEpic1]);
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(DropdownContents).exists()).toBe(true); expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(2);
done();
});
}); });
it('should render GlLoadingIcon component when props `canEdit` & `showDropdown` and `isEpicsLoading` are true', (done) => { it('should render GlLoadingIcon component when props `canEdit` & `showDropdown` and `isEpicsLoading` are true', async () => {
showDropdown(); showDropdown();
store.dispatch('requestEpics'); store.dispatch('requestEpics');
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.findComponent(GlDropdown).findComponent(GlLoadingIcon).exists()).toBe(true);
done();
});
}); });
}); });
}); });
......
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DropdownButton from 'ee/vue_shared/components/sidebar/epics_select/dropdown_button.vue';
import { mockEpic1 } from '../mock_data';
describe('EpicsSelect', () => {
describe('DropdownButton', () => {
let wrapper;
let wrapperWithEpic;
beforeEach(() => {
wrapper = shallowMount(DropdownButton);
wrapperWithEpic = shallowMount(DropdownButton, {
propsData: {
selectedEpicTitle: mockEpic1.title,
},
});
});
afterEach(() => {
wrapper.destroy();
wrapperWithEpic.destroy();
});
describe('computed', () => {
describe('buttonText', () => {
it('returns string "Epic" when `selectedEpicTitle` prop is empty', () => {
expect(wrapper.vm.buttonText).toBe('Epic');
});
it('returns string containing `selectedEpicTitle`', () => {
expect(wrapperWithEpic.vm.buttonText).toBe(mockEpic1.title);
});
});
});
describe('template', () => {
it('should render button element', () => {
expect(wrapper.element.tagName).toBe('BUTTON');
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['dropdown-menu-toggle', 'js-epic-select', 'js-extra-options']),
);
expect(wrapper.attributes('data-display')).toBe('static');
expect(wrapper.attributes('data-toggle')).toBe('dropdown');
});
it('should render button title', () => {
const titleEl = wrapper.find('.dropdown-toggle-text');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe('Epic');
const titleWithEpicEl = wrapperWithEpic.find('.dropdown-toggle-text');
expect(titleWithEpicEl.exists()).toBe(true);
expect(titleWithEpicEl.text()).toBe(mockEpic1.title);
});
it('should render button title with toggleTextClass prop value', () => {
wrapper.setProps({
toggleTextClass: { 'is-default': true },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.dropdown-toggle-text').classes()).toContain('is-default');
});
});
it('should render Icon component', () => {
const iconEl = wrapper.find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('chevron-down');
});
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DropdownContents from 'ee/vue_shared/components/sidebar/epics_select/dropdown_contents.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { mockEpic1, mockEpic2, mockEpics, noneEpic } from '../mock_data';
const epics = mockEpics.map((epic) => convertObjectPropsToCamelCase(epic));
describe('EpicsSelect', () => {
describe('DropdownContents', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownContents, {
propsData: {
epics,
selectedEpic: mockEpic1,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('isNoEpic', () => {
it('should return true when `selectedEpic` is of type `No Epic`', (done) => {
wrapper.setProps({
selectedEpic: noneEpic,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isNoEpic).toBe(true);
done();
});
});
it('should return false when `selectedEpic` is an epic', () => {
expect(wrapper.vm.isNoEpic).toBe(false);
});
});
});
describe('methods', () => {
describe('isSelected', () => {
it('should return true when passed `epic` param ID is same as `selectedEpic` prop', () => {
expect(wrapper.vm.isSelected(mockEpic1)).toBe(true);
});
it('should return false when passed `epic` param ID is different from `selectedEpic` prop', () => {
expect(wrapper.vm.isSelected(mockEpic2)).toBe(false);
});
});
describe('handleItemClick', () => {
it('should emit `onItemSelect` event with `epic` param when passed `epic` param is different from already selected epic', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.handleItemClick(mockEpic2);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onItemSelect', mockEpic2);
});
it('should emit `onItemSelect` event with `No Epic` param when passed `epic` param is same as already selected epic', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.handleItemClick(mockEpic1);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onItemSelect', noneEpic);
});
});
});
describe('template', () => {
it('should render container element', () => {
expect(wrapper.classes()).toContain('dropdown-content');
});
it('should render `No Epic` as first item within list', () => {
const noneEl = wrapper.find('ul > li');
expect(noneEl.attributes('data-epic-id')).toBe('None');
expect(noneEl.find(GlLink).exists()).toBe(true);
expect(noneEl.find(GlLink).text()).toBe('No Epic');
});
it('should render epics list for all provided epics', () => {
const epicsEl = wrapper.findAll('ul > li');
expect(epicsEl).toHaveLength(epics.length + 2); // includes divider & No Epic` <li>.
expect(epicsEl.at(1).classes()).toContain('divider');
expect(epicsEl.at(2).find(GlLink).text()).toBe(epics[0].title);
expect(epicsEl.at(3).find(GlLink).text()).toBe(epics[1].title);
expect(epicsEl.at(2).find(GlLink).classes()).toContain('is-active');
});
it('should render string "No matches found" when `epics` array is empty', () => {
wrapper.setProps({
epics: [],
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain('No matches found');
});
});
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DropdownHeader from 'ee/vue_shared/components/sidebar/epics_select/dropdown_header.vue';
describe('EpicsSelect', () => {
describe('DropdownHeader', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownHeader);
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render container element', () => {
expect(wrapper.classes()).toContain('dropdown-title');
});
it('should render title', () => {
expect(wrapper.find('span').text()).toBe('Assign epic');
});
it('should render close button', () => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.attributes('aria-label')).toBe('Close');
expect(buttonEl.classes()).toEqual(
expect.arrayContaining(['dropdown-title-button', 'dropdown-menu-close']),
);
expect(buttonEl.props('icon')).toBe('close');
});
});
});
});
import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DropdownSearchInput from 'ee/vue_shared/components/sidebar/epics_select/dropdown_search_input.vue';
const createComponent = () =>
shallowMount(DropdownSearchInput, {
directives: {
/**
* We don't want any observers
* initialized during tests that this
* directive does.
*/
autofocusonshow: {},
},
});
describe('EpicsSelect', () => {
describe('DropdownSearchInput', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleKeyUp', () => {
it('should emit `onSearchInput` on component with `query` param', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.setData({
query: 'foo',
});
wrapper.vm.handleKeyUp();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onSearchInput', 'foo');
});
});
describe('handleInputClear', () => {
it('should set `query` prop to empty string and calls `handleKeyUp`', () => {
jest.spyOn(wrapper.vm, 'handleKeyUp');
wrapper.setData({
query: 'foo',
});
wrapper.vm.handleInputClear();
expect(wrapper.vm.query).toBe('');
expect(wrapper.vm.handleKeyUp).toHaveBeenCalled();
});
});
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toContain('dropdown-input');
expect(wrapper.classes()).not.toContain('has-value');
});
it('should add `has-value` class to container when `query` prop is not empty', () => {
wrapper.setData({
query: 'foo',
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.classes()).toContain('has-value');
});
});
it('should render input element', () => {
const inputEl = wrapper.find('input');
expect(inputEl.exists()).toBe(true);
expect(inputEl.classes()).toContain('dropdown-input-field');
expect(inputEl.attributes('placeholder')).toBe('Search');
expect(inputEl.attributes('type')).toBe('search');
expect(inputEl.attributes('autocomplete')).toBe('off');
});
it('should render Icon component', () => {
wrapper.setData({
query: 'foo',
});
return wrapper.vm.$nextTick().then(() => {
const iconEl = wrapper.find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('search');
});
});
it('should render input clear button', () => {
const clearButtonEl = wrapper.find(GlButton);
expect(clearButtonEl.exists()).toBe(true);
expect(clearButtonEl.classes()).toEqual(
expect.arrayContaining(['dropdown-input-clear', 'js-dropdown-input-clear']),
);
});
});
});
});
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DropdownTitle from 'ee/vue_shared/components/sidebar/epics_select/dropdown_title.vue';
describe('EpicsSelect', () => {
describe('DropdownTitle', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownTitle, {
propsData: {
canEdit: false,
blockTitle: 'Epic',
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toEqual(expect.arrayContaining(['title', 'hide-collapsed']));
});
it('should render title element', () => {
wrapper.setProps({
isLoading: true,
});
return wrapper.vm.$nextTick().then(() => {
const titleEl = wrapper.find('.flex-grow-1');
expect(titleEl.exists()).toBe(true);
expect(titleEl.find('span').classes()).toContain('align-text-top');
expect(titleEl.find('span').text()).toBe('Epic');
});
});
it('should render loading icon when `isLoading` prop is true', () => {
wrapper.setProps({
isLoading: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
it('should render edit link when `canEdit` prop is true', () => {
wrapper.setProps({
canEdit: true,
});
return wrapper.vm.$nextTick().then(() => {
const editEl = wrapper.find(GlLink);
expect(editEl.exists()).toBe(true);
expect(editEl.classes()).toContain('sidebar-dropdown-toggle');
expect(editEl.text()).toBe('Edit');
});
});
});
});
});
...@@ -3927,9 +3927,6 @@ msgstr "" ...@@ -3927,9 +3927,6 @@ msgstr ""
msgid "Assign custom color like #FF0000" msgid "Assign custom color like #FF0000"
msgstr "" msgstr ""
msgid "Assign epic"
msgstr ""
msgid "Assign labels" msgid "Assign labels"
msgstr "" msgstr ""
...@@ -11354,6 +11351,9 @@ msgstr "" ...@@ -11354,6 +11351,9 @@ msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?" msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr "" msgstr ""
msgid "Epics|Assign Epic"
msgstr ""
msgid "Epics|Enter a title for your epic" msgid "Epics|Enter a title for your epic"
msgstr "" msgstr ""
...@@ -11369,6 +11369,12 @@ msgstr "" ...@@ -11369,6 +11369,12 @@ msgstr ""
msgid "Epics|Remove issue" msgid "Epics|Remove issue"
msgstr "" msgstr ""
msgid "Epics|Search epics"
msgstr ""
msgid "Epics|Select epic"
msgstr ""
msgid "Epics|Show more" msgid "Epics|Show more"
msgstr "" msgstr ""
......
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