Commit 905c32b5 authored by Mark Florian's avatar Mark Florian

Merge branch '34640-async-search-epics-dropdown' into 'master'

Add support for async search within Epics dropdown

See merge request gitlab-org/gitlab!26980
parents e25c11f0 d44479f2
......@@ -8,8 +8,7 @@ export default {
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics',
groupEpicsPath:
'/api/:version/groups/:id/epics?include_ancestor_groups=:includeAncestorGroups&include_descendant_groups=:includeDescendantGroups',
groupEpicsPath: '/api/:version/groups/:id/epics',
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
groupPackagesPath: '/api/:version/groups/:id/packages',
projectPackagesPath: '/api/:version/projects/:id/packages',
......@@ -64,13 +63,25 @@ export default {
});
},
groupEpics({ groupId, includeAncestorGroups = false, includeDescendantGroups = true }) {
const url = Api.buildUrl(this.groupEpicsPath)
.replace(':id', groupId)
.replace(':includeAncestorGroups', includeAncestorGroups)
.replace(':includeDescendantGroups', includeDescendantGroups);
groupEpics({
groupId,
includeAncestorGroups = false,
includeDescendantGroups = true,
search = '',
}) {
const url = Api.buildUrl(this.groupEpicsPath).replace(':id', groupId);
const params = {
include_ancestor_groups: includeAncestorGroups,
include_descendant_groups: includeDescendantGroups,
};
return axios.get(url);
if (search) {
params.search = search;
}
return axios.get(url, {
params,
});
},
addEpicIssue({ groupId, epicIid, issueId }) {
......
......@@ -65,7 +65,7 @@ export default {
};
},
computed: {
...mapState(['epicSelectInProgress', 'epicsFetchInProgress', 'selectedEpic']),
...mapState(['epicSelectInProgress', 'epicsFetchInProgress', 'selectedEpic', 'searchQuery']),
...mapGetters(['groupEpics']),
dropdownSelectInProgress() {
return this.initialEpicLoading || this.epicSelectInProgress;
......@@ -95,6 +95,17 @@ export default {
initialEpicLoading() {
this.setSelectedEpic(this.initialEpic);
},
/**
* Check if `searchQuery` presence has yielded any matching
* epics, if not, dispatch `fetchEpics` with search query.
*/
searchQuery(value) {
if (value) {
if (!this.groupEpics.length) this.fetchEpics(this.searchQuery);
} else {
this.fetchEpics();
}
},
},
mounted() {
this.setInitialData({
......@@ -103,7 +114,7 @@ export default {
selectedEpic: this.selectedEpic,
selectedEpicIssueId: this.epicIssueId,
});
$(this.$refs.dropdown).on('shown.bs.dropdown', this.handleDropdownShown);
$(this.$refs.dropdown).on('shown.bs.dropdown', () => this.fetchEpics());
$(this.$refs.dropdown).on('hidden.bs.dropdown', this.handleDropdownHidden);
},
methods: {
......@@ -131,9 +142,6 @@ export default {
});
});
},
handleDropdownShown() {
if (this.groupEpics.length === 0) this.fetchEpics();
},
handleDropdownHidden() {
this.showDropdown = false;
},
......
......@@ -52,13 +52,16 @@ export default {
>
</li>
<li class="divider"></li>
<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 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 { debounce } from 'lodash';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
......@@ -18,9 +19,9 @@ export default {
};
},
methods: {
handleKeyUp() {
handleKeyUp: debounce(function debouncedKeyUp() {
this.$emit('onSearchInput', this.query);
},
}, 300),
handleInputClear() {
this.query = '';
this.handleKeyUp();
......
......@@ -30,13 +30,14 @@ export const receiveEpicsFailure = ({ commit }) => {
flash(s__('Epics|Something went wrong while fetching group epics.'));
commit(types.RECEIVE_EPICS_FAILURE);
};
export const fetchEpics = ({ state, dispatch }) => {
export const fetchEpics = ({ state, dispatch }, search = '') => {
dispatch('requestEpics');
Api.groupEpics({
groupId: state.groupId,
includeDescendantGroups: false,
includeAncestorGroups: true,
search,
})
.then(({ data }) => {
dispatch('receiveEpicsSuccess', data);
......
---
title: Add support for async search within Epics dropdown
merge_request: 26980
author:
type: fixed
......@@ -46,7 +46,9 @@ describe 'Epic in issue sidebar', :js do
page.find('.dropdown-input-field').send_keys('Foo')
expect(page.all('.dropdown-content li a').length).to eq(2) # `No Epic` + 1 matching epic
wait_for_requests
expect(page).to have_selector('.dropdown-content li a', count: 2) # `No Epic` + 1 matching epic
end
end
......
......@@ -90,9 +90,16 @@ describe('Api', () => {
describe('groupEpics', () => {
it('calls `axios.get` using param `groupId`', done => {
const groupId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics?include_ancestor_groups=false&include_descendant_groups=true`;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
mock.onGet(expectedUrl).reply(200, mockEpics);
mock
.onGet(expectedUrl, {
params: {
include_ancestor_groups: false,
include_descendant_groups: true,
},
})
.reply(200, mockEpics);
Api.groupEpics({ groupId })
.then(({ data }) => {
......@@ -106,6 +113,33 @@ describe('Api', () => {
.then(done)
.catch(done.fail);
});
it('calls `axios.get` using param `search` when it is provided', done => {
const groupId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
mock
.onGet(expectedUrl, {
params: {
include_ancestor_groups: false,
include_descendant_groups: true,
search: 'foo',
},
})
.reply(200, mockEpics);
Api.groupEpics({ groupId, search: 'foo' })
.then(({ data }) => {
data.forEach((epic, index) => {
expect(epic.id).toBe(mockEpics[index].id);
expect(epic.iid).toBe(mockEpics[index].iid);
expect(epic.group_id).toBe(mockEpics[index].group_id);
expect(epic.title).toBe(mockEpics[index].title);
});
})
.then(done)
.catch(done.fail);
});
});
describe('addEpicIssue', () => {
......
......@@ -13,14 +13,7 @@ import DropdownContents from 'ee/vue_shared/components/sidebar/epics_select/drop
import createDefaultStore from 'ee/vue_shared/components/sidebar/epics_select/store';
import {
mockEpic1,
mockEpic2,
mockEpics,
mockAssignRemoveRes,
mockIssue,
noneEpic,
} from '../mock_data';
import { mockEpic1, mockEpic2, mockAssignRemoveRes, mockIssue, noneEpic } from '../mock_data';
describe('EpicsSelect', () => {
describe('Base', () => {
......@@ -82,29 +75,32 @@ describe('EpicsSelect', () => {
expect(wrapper.vm.$store.state.selectedEpic).toBe(mockEpic2);
});
});
});
describe('methods', () => {
describe('handleDropdownShown', () => {
it('should call `fetchEpics` when `groupEpics` does not return any epics', done => {
jest.spyOn(wrapper.vm, 'fetchEpics').mockReturnValue(
Promise.resolve({
data: mockEpics,
}),
);
describe('searchQuery', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'fetchEpics').mockImplementation(jest.fn());
});
store.dispatch('receiveEpicsSuccess', []);
it('should call action `fetchEpics` with `searchQuery` when value is set and `groupEpics` is empty', () => {
wrapper.vm.$store.dispatch('receiveEpicsSuccess', []);
wrapper.vm.$store.dispatch('setSearchQuery', 'foo');
wrapper.vm.$nextTick(() => {
wrapper.vm.handleDropdownShown();
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo');
});
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
it('should call action `fetchEpics` without any params when value is empty', () => {
wrapper.vm.$store.dispatch('setSearchQuery', '');
done();
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith();
});
});
});
});
describe('methods', () => {
describe('handleDropdownHidden', () => {
it('should set `showDropdown` to false', () => {
wrapper.vm.handleDropdownHidden();
......
......@@ -111,6 +111,16 @@ describe('EpicsSelect', () => {
.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');
});
});
});
});
});
......@@ -4,6 +4,8 @@ import { GlButton } from '@gitlab/ui';
import DropdownSearchInput from 'ee/vue_shared/components/sidebar/epics_select/dropdown_search_input.vue';
import Icon from '~/vue_shared/components/icon.vue';
jest.mock('lodash/debounce', () => jest.fn(fn => fn));
const createComponent = () =>
shallowMount(DropdownSearchInput, {
directives: {
......
......@@ -193,15 +193,19 @@ describe('EpicsSelect', () => {
}),
);
actions.fetchEpics({
state,
dispatch: () => {},
});
actions.fetchEpics(
{
state,
dispatch: () => {},
},
'foo',
);
expect(Api.groupEpics).toHaveBeenCalledWith({
groupId: state.groupId,
includeDescendantGroups: false,
includeAncestorGroups: true,
search: 'foo',
});
});
});
......
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