Commit d44479f2 authored by Kushal Pandya's avatar Kushal Pandya

Add support for async search of Epics

Search for Epics asynchronously when no matches are
present in locally loaded list.
parent 97dc741e
......@@ -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