Commit 7786e2c9 authored by Kushal Pandya's avatar Kushal Pandya

Support async loading & search of projects

Add support asynchronous search of projects within
dropdown while creating Issue from Epics Tree.
parent 74244377
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormInput } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import {
GlButton,
GlDropdown,
GlDropdownItem,
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'underscore';
import { __ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { SEARCH_DEBOUNCE } from '../constants';
export default {
components: {
......@@ -11,24 +20,20 @@ export default {
GlDropdown,
GlDropdownItem,
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
ProjectAvatar,
},
props: {
projects: {
type: Array,
required: true,
},
},
data() {
return {
selectedProject: null,
searchKey: '',
title: '',
preventDropdownClose: false,
};
},
computed: {
...mapState(['projectsFetchInProgress', 'itemCreateInProgress', 'projects']),
dropdownToggleText() {
if (this.selectedProject) {
return this.selectedProject.name_with_namespace;
......@@ -36,19 +41,37 @@ export default {
return __('Select a project');
},
hasValidInput() {
return this.title.trim() !== '' && this.selectedProject;
},
watch: {
/**
* We're using `debounce` here as `GlSearchBoxByType` doesn't
* support `lazy` or `debounce` props as per https://bootstrap-vue.js.org/docs/components/form-input/.
* This is a known GitLab UI issue https://gitlab.com/gitlab-org/gitlab-ui/-/issues/631
*/
searchKey: debounce(function debounceSearch() {
this.fetchProjects(this.searchKey);
}, SEARCH_DEBOUNCE),
/**
* As Issue Create Form already has `autofocus` set for
* Issue title field, we cannot leverage `autofocus` prop
* again for search input field, so we manually set
* focus only when dropdown is opened and content is loaded.
*/
projectsFetchInProgress(value) {
if (!value) {
this.$nextTick(() => {
this.$refs.searchInputField.focusInput();
});
}
},
},
methods: {
...mapActions(['fetchProjects']),
cancel() {
this.$emit('cancel');
},
createIssue() {
if (!this.hasValidInput) {
if (!this.selectedProject) {
return;
}
......@@ -56,6 +79,36 @@ export default {
const { issues: issuesEndpoint } = selectedProject._links;
this.$emit('submit', { issuesEndpoint, title });
},
handleDropdownShow() {
this.searchKey = '';
this.fetchProjects();
},
handleDropdownHide(e) {
// Check if dropdown closure is to be prevented.
if (this.preventDropdownClose) {
e.preventDefault();
this.preventDropdownClose = false;
}
},
/**
* As GlDropdown can get closed if any item within
* it is clicked, we have to work around that behaviour
* by preventing dropdown close if user has clicked
* clear button on search input field. This hack
* won't be required once we add support for
* `BDropdownForm` https://bootstrap-vue.js.org/docs/components/dropdown#b-dropdown-form
* within GitLab UI.
*/
handleSearchInputContainerClick({ target }) {
// Check if clicked target was an icon.
if (
target?.classList.contains('gl-icon') ||
target?.getAttribute('href')?.includes('clear')
) {
// Enable flag to prevent dropdown close.
this.preventDropdownClose = true;
}
},
},
};
</script>
......@@ -67,7 +120,7 @@ export default {
<label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input
ref="titleInput"
v-model="title"
v-model.trim="title"
:placeholder="__('New issue title')"
autofocus
/>
......@@ -75,30 +128,55 @@ export default {
<div class="col-sm">
<label class="label-bold">{{ __('Project') }}</label>
<gl-dropdown
ref="dropdownButton"
:text="dropdownToggleText"
class="w-100"
menu-class="w-100"
class="w-100 projects-dropdown"
menu-class="w-100 overflow-hidden"
toggle-class="d-flex align-items-center justify-content-between text-truncate"
@show="handleDropdownShow"
@hide="handleDropdownHide"
>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
class="w-100"
@click="selectedProject = project"
>
<project-avatar :project="project" :size="32" />
{{ project.name }}
<div class="text-secondary">{{ project.namespace.name }}</div>
</gl-dropdown-item>
<div class="mx-2 mb-1" @click="handleSearchInputContainerClick">
<gl-search-box-by-type
ref="searchInputField"
v-model="searchKey"
:disabled="projectsFetchInProgress"
/>
</div>
<gl-loading-icon
v-show="projectsFetchInProgress"
class="projects-fetch-loading align-items-center p-2"
size="md"
/>
<div v-if="!projectsFetchInProgress" class="dropdown-contents overflow-auto p-1">
<span v-if="!projects.length" class="d-block text-center p-2">{{
__('No matches found')
}}</span>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
class="w-100"
@click="selectedProject = project"
>
<project-avatar :project="project" :size="32" />
{{ project.name }}
<div class="text-secondary">{{ project.namespace.name }}</div>
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
</div>
<div class="row my-1">
<div class="col-sm flex-sm-grow-0 mb-2 mb-sm-0">
<gl-button class="w-100" variant="success" :disabled="!hasValidInput" @click="createIssue">
{{ __('Create issue') }}
</gl-button>
<gl-button
class="w-100"
variant="success"
:disabled="!selectedProject || itemCreateInProgress"
:loading="itemCreateInProgress"
@click="createIssue"
>{{ __('Create issue') }}</gl-button
>
</div>
<div class="col-sm flex-sm-grow-0 ml-auto">
<gl-button class="w-100" @click="cancel">{{ __('Cancel') }}</gl-button>
......
......@@ -37,11 +37,6 @@ export default {
IssueActionsSplitButton,
SlotSwitch,
},
data() {
return {
isCreateIssueFormVisible: false,
};
},
computed: {
...mapState([
'parentItem',
......@@ -54,6 +49,7 @@ export default {
'itemCreateInProgress',
'showAddItemForm',
'showCreateEpicForm',
'showCreateIssueForm',
'autoCompleteEpics',
'autoCompleteIssues',
'pendingReferences',
......@@ -61,7 +57,6 @@ export default {
'issuableType',
'epicsEndpoint',
'issuesEndpoint',
'projects',
]),
...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']),
disableContents() {
......@@ -76,7 +71,7 @@ export default {
return FORM_SLOTS.createEpic;
}
if (this.isCreateIssueFormVisible) {
if (this.showCreateIssueForm) {
return FORM_SLOTS.createIssue;
}
......@@ -93,6 +88,7 @@ export default {
'fetchItems',
'toggleAddItemForm',
'toggleCreateEpicForm',
'toggleCreateIssueForm',
'setPendingReferences',
'addPendingReferences',
'removePendingReference',
......@@ -137,15 +133,11 @@ export default {
this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue('');
},
showAddIssueForm() {
handleShowAddIssueForm() {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
},
showCreateIssueForm() {
return this.fetchProjects().then(() => {
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
});
handleShowCreateIssueForm() {
this.toggleCreateIssueForm({ toggleState: true });
},
},
};
......@@ -168,8 +160,8 @@ export default {
<issue-actions-split-button
slot="issueActions"
class="ml-1"
@showAddIssueForm="showAddIssueForm"
@showCreateIssueForm="showCreateIssueForm"
@showAddIssueForm="handleShowAddIssueForm"
@showCreateIssueForm="handleShowCreateIssueForm"
/>
</related-items-tree-header>
<slot-switch
......@@ -206,8 +198,7 @@ export default {
/>
<create-issue-form
:slot="$options.FORM_SLOTS.createIssue"
:projects="projects"
@cancel="isCreateIssueFormVisible = false"
@cancel="toggleCreateIssueForm({ toggleState: false })"
@submit="createNewIssue"
/>
</slot-switch>
......
......@@ -39,4 +39,6 @@ export const RemoveItemModalProps = {
export const OVERFLOW_AFTER = 5;
export const SEARCH_DEBOUNCE = 500;
export const itemRemoveModalId = 'item-remove-confirmation';
......@@ -252,6 +252,8 @@ export const removeItem = ({ dispatch }, { parentItem, item }) => {
export const toggleAddItemForm = ({ commit }, data) => commit(types.TOGGLE_ADD_ITEM_FORM, data);
export const toggleCreateEpicForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_EPIC_FORM, data);
export const toggleCreateIssueForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_ISSUE_FORM, data);
export const setPendingReferences = ({ commit }, data) =>
commit(types.SET_PENDING_REFERENCES, data);
......@@ -448,42 +450,62 @@ export const reorderItem = (
});
};
export const receiveCreateIssueSuccess = ({ commit }) =>
commit(types.RECEIVE_CREATE_ITEM_SUCCESS, { insertAt: 0, items: [] });
export const receiveCreateIssueFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_ITEM_FAILURE);
flash(s__('Epics|Something went wrong while creating issue.'));
};
export const createNewIssue = ({ state, dispatch }, { issuesEndpoint, title }) => {
const { parentItem } = state;
// necessary because parentItem comes from GraphQL and we are using REST API here
const epicId = parseInt(parentItem.id.replace(/^gid:\/\/gitlab\/Epic\//, ''), 10);
dispatch('requestCreateItem');
return axios
.post(issuesEndpoint, { epic_id: epicId, title })
.then(() =>
.then(({ data }) => {
dispatch('receiveCreateIssueSuccess', data);
dispatch('fetchItems', {
parentItem,
}),
)
});
})
.catch(e => {
flash(__('Could not create issue'));
dispatch('receiveCreateIssueFailure');
throw e;
});
};
export const fetchProjects = ({ state, commit }) =>
export const requestProjects = ({ commit }) => commit(types.REQUEST_PROJECTS);
export const receiveProjectsSuccess = ({ commit }, data) =>
commit(types.RECIEVE_PROJECTS_SUCCESS, data);
export const receiveProjectsFailure = ({ commit }) => {
commit(types.RECIEVE_PROJECTS_FAILURE);
flash(__('Something went wrong while fetching projects.'));
};
export const fetchProjects = ({ state, dispatch }, searchKey = '') => {
const params = {
include_subgroups: true,
order_by: 'last_activity_at',
with_issues_enabled: true,
with_shared: false,
};
if (searchKey) {
params.search = searchKey;
}
dispatch('requestProjects');
axios
.get(state.projectsEndpoint, {
params: {
include_subgroups: true,
order_by: 'last_activity_at',
with_issues_enabled: true,
with_shared: false,
},
params,
})
.then(({ data }) => {
commit(types.SET_PROJECTS, data);
dispatch('receiveProjectsSuccess', data);
})
.catch(e => {
flash(__('Could not fetch projects'));
throw e;
});
.catch(() => dispatch('receiveProjectsFailure'));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -22,6 +22,7 @@ export const COLLAPSE_ITEM = 'COLLAPSE_ITEM';
export const TOGGLE_ADD_ITEM_FORM = 'TOGGLE_ADD_ITEM_FORM';
export const TOGGLE_CREATE_EPIC_FORM = 'TOGGLE_CREATE_EPIC_FORM';
export const TOGGLE_CREATE_ISSUE_FORM = 'TOGGLE_CREATE_ISSUE_FORM';
export const SET_PENDING_REFERENCES = 'SET_PENDING_REFERENCES';
export const ADD_PENDING_REFERENCES = 'ADD_PENDING_REFERENCES';
......@@ -40,3 +41,6 @@ export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE';
export const REORDER_ITEM = 'REORDER_ITEM';
export const SET_PROJECTS = 'SET_PROJECTS';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
export const RECIEVE_PROJECTS_SUCCESS = 'RECIEVE_PROJECTS_SUCCESS';
export const RECIEVE_PROJECTS_FAILURE = 'RECIEVE_PROJECTS_FAILURE';
......@@ -144,6 +144,11 @@ export default {
state.showAddItemForm = false;
},
[types.TOGGLE_CREATE_ISSUE_FORM](state, { toggleState }) {
state.showCreateIssueForm = toggleState;
state.showAddItemForm = false;
},
[types.SET_PENDING_REFERENCES](state, references) {
state.pendingReferences = references;
},
......@@ -203,7 +208,14 @@ export default {
state.children[parentItem.reference].splice(newIndex, 0, targetItem);
},
[types.SET_PROJECTS](state, projects) {
[types.REQUEST_PROJECTS](state) {
state.projectsFetchInProgress = true;
},
[types.RECIEVE_PROJECTS_SUCCESS](state, projects) {
state.projects = projects;
state.projectsFetchInProgress = false;
},
[types.RECIEVE_PROJECTS_FAILURE](state) {
state.projectsFetchInProgress = false;
},
};
......@@ -32,8 +32,10 @@ export default () => ({
itemAddInProgress: false,
itemAddFailure: false,
itemCreateInProgress: false,
projectsFetchInProgress: false,
showAddItemForm: false,
showCreateEpicForm: false,
showCreateIssueForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
allowSubEpics: false,
......
......@@ -4,6 +4,10 @@
.add-item-form-container {
border-bottom: 1px solid $border-color;
.projects-dropdown .dropdown-contents {
max-height: $dropdown-max-height - 50;
}
}
.sub-tree-root {
......
---
title: Support async loading & search of projects within Epics Tree
merge_request: 26661
author:
type: added
......@@ -17,6 +17,8 @@ import { getJSONFixture } from 'helpers/fixtures';
import {
mockInitialConfig,
mockParentItem,
mockEpics,
mockIssues,
} from '../../../javascripts/related_items_tree/mock_data';
const mockProjects = getJSONFixture('static/projects.json');
......@@ -26,6 +28,10 @@ const createComponent = () => {
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
children: [...mockEpics, ...mockIssues],
});
return shallowMount(RelatedItemsTreeApp, {
store,
......@@ -41,7 +47,6 @@ describe('RelatedItemsTreeApp', () => {
const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
const showCreateIssueForm = () => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
return axios.waitFor(mockInitialConfig.projectsEndpoint).then(() => wrapper.vm.$nextTick());
};
beforeEach(() => {
......@@ -273,11 +278,10 @@ describe('RelatedItemsTreeApp', () => {
it('shows create item form', () => {
expect(findCreateIssueForm().exists()).toBe(false);
return showCreateIssueForm().then(() => {
const form = findCreateIssueForm();
showCreateIssueForm();
expect(form.exists()).toBe(true);
expect(form.props().projects).toBe(mockProjects);
return wrapper.vm.$nextTick(() => {
expect(findCreateIssueForm().exists()).toBe(true);
});
});
});
......
......@@ -392,6 +392,17 @@ describe('RelatedItemsTree', () => {
});
});
describe(types.TOGGLE_CREATE_ISSUE_FORM, () => {
it('should set value of `showCreateIssueForm` as it is and `showAddItemForm` as false on state', () => {
const data = { toggleState: true };
mutations[types.TOGGLE_CREATE_ISSUE_FORM](state, data);
expect(state.showCreateIssueForm).toBe(data.toggleState);
expect(state.showAddItemForm).toBe(false);
});
});
describe(types.SET_PENDING_REFERENCES, () => {
it('should set `pendingReferences` to state based on provided `references` param', () => {
const reference = ['foo'];
......@@ -546,6 +557,33 @@ describe('RelatedItemsTree', () => {
);
});
});
describe(types.REQUEST_PROJECTS, () => {
it('should set `projectsFetchInProgress` to true within state', () => {
mutations[types.REQUEST_PROJECTS](state);
expect(state.projectsFetchInProgress).toBe(true);
});
});
describe(types.RECIEVE_PROJECTS_SUCCESS, () => {
it('should set `projectsFetchInProgress` to false and provided `projects` param as it is within the state', () => {
const projects = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
mutations[types.RECIEVE_PROJECTS_SUCCESS](state, projects);
expect(state.projects).toBe(projects);
expect(state.projectsFetchInProgress).toBe(false);
});
});
describe(types.RECIEVE_PROJECTS_FAILURE, () => {
it('should set `projectsFetchInProgress` to false within state', () => {
mutations[types.RECIEVE_PROJECTS_FAILURE](state);
expect(state.projectsFetchInProgress).toBe(false);
});
});
});
});
});
......@@ -27,6 +27,8 @@ import {
mockEpic1,
} from '../mock_data';
const mockProjects = getJSONFixture('static/projects.json');
describe('RelatedItemTree', () => {
describe('store', () => {
describe('actions', () => {
......@@ -801,6 +803,19 @@ describe('RelatedItemTree', () => {
});
});
describe('toggleCreateIssueForm', () => {
it('should set `state.showCreateIssueForm` to true and `state.showAddItemForm` to false', done => {
testAction(
actions.toggleCreateIssueForm,
{},
{},
[{ type: types.TOGGLE_CREATE_ISSUE_FORM, payload: {} }],
[],
done,
);
});
});
describe('setPendingReferences', () => {
it('should set param value to `state.pendingReference`', done => {
testAction(
......@@ -1325,6 +1340,52 @@ describe('RelatedItemTree', () => {
});
});
describe('receiveCreateIssueSuccess', () => {
it('should set `state.itemCreateInProgress` & `state.itemsFetchResultEmpty` to false', done => {
testAction(
actions.receiveCreateIssueSuccess,
{ insertAt: 0, items: [] },
{},
[{ type: types.RECEIVE_CREATE_ITEM_SUCCESS, payload: { insertAt: 0, items: [] } }],
[],
done,
);
});
});
describe('receiveCreateIssueFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.itemCreateInProgress` to false', done => {
testAction(
actions.receiveCreateIssueFailure,
{},
{},
[{ type: types.RECEIVE_CREATE_ITEM_FAILURE }],
[],
done,
);
});
it('should show flash error with message "Something went wrong while creating issue."', () => {
const message = 'Something went wrong while creating issue.';
actions.receiveCreateIssueFailure(
{
commit: () => {},
},
{
message,
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('createNewIssue', () => {
const issuesEndpoint = `${TEST_HOST}/issues`;
const title = 'new issue title';
......@@ -1382,6 +1443,8 @@ describe('RelatedItemTree', () => {
.createNewIssue(context, payload)
.then(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).toHaveBeenCalledWith('requestCreateItem');
expect(context.dispatch).toHaveBeenCalledWith('receiveCreateIssueSuccess', '');
expect(context.dispatch).toHaveBeenCalledWith(
'fetchItems',
jasmine.objectContaining({ parentItem }),
......@@ -1405,14 +1468,120 @@ describe('RelatedItemTree', () => {
.then(() => done.fail('expected action to throw error!'))
.catch(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).not.toHaveBeenCalled();
expect(flashSpy).toHaveBeenCalled();
expect(context.dispatch).toHaveBeenCalledWith('receiveCreateIssueFailure');
})
.then(done)
.catch(done.fail);
});
});
});
describe('requestProjects', () => {
it('should set `state.projectsFetchInProgress` to true', done => {
testAction(actions.requestProjects, {}, {}, [{ type: types.REQUEST_PROJECTS }], [], done);
});
});
describe('receiveProjectsSuccess', () => {
it('should set `state.projectsFetchInProgress` to false and set provided `projects` param to state', done => {
testAction(
actions.receiveProjectsSuccess,
mockProjects,
{},
[{ type: types.RECIEVE_PROJECTS_SUCCESS, payload: mockProjects }],
[],
done,
);
});
});
describe('receiveProjectsFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should set `state.projectsFetchInProgress` to false', done => {
testAction(
actions.receiveProjectsFailure,
{},
{},
[{ type: types.RECIEVE_PROJECTS_FAILURE }],
[],
done,
);
});
it('should show flash error with message "Something went wrong while fetching projects."', () => {
const message = 'Something went wrong while fetching projects.';
actions.receiveProjectsFailure(
{
commit: () => {},
},
{
message,
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('fetchProjects', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.parentItem = mockParentItem;
state.issuableType = issuableTypesMap.EPIC;
});
afterEach(() => {
mock.restore();
});
it('should dispatch `requestProjects` and `receiveProjectsSuccess` actions on request success', done => {
mock.onGet(/(.*)/).replyOnce(200, mockProjects);
testAction(
actions.fetchProjects,
'',
state,
[],
[
{
type: 'requestProjects',
},
{
type: 'receiveProjectsSuccess',
payload: mockProjects,
},
],
done,
);
});
it('should dispatch `requestProjects` and `receiveProjectsFailure` actions on request failure', done => {
mock.onGet(/(.*)/).replyOnce(500, {});
testAction(
actions.fetchProjects,
'',
state,
[],
[
{
type: 'requestProjects',
},
{
type: 'receiveProjectsFailure',
},
],
done,
);
});
});
});
});
});
......@@ -5630,9 +5630,6 @@ msgstr ""
msgid "Could not create group"
msgstr ""
msgid "Could not create issue"
msgstr ""
msgid "Could not create project"
msgstr ""
......@@ -5642,9 +5639,6 @@ msgstr ""
msgid "Could not delete chat nickname %{chat_name}."
msgstr ""
msgid "Could not fetch projects"
msgstr ""
msgid "Could not find design"
msgstr ""
......@@ -7840,6 +7834,9 @@ msgstr ""
msgid "Epics|Something went wrong while creating child epics."
msgstr ""
msgid "Epics|Something went wrong while creating issue."
msgstr ""
msgid "Epics|Something went wrong while fetching child epics."
msgstr ""
......@@ -13178,6 +13175,9 @@ msgstr ""
msgid "No licenses found."
msgstr ""
msgid "No matches found"
msgstr ""
msgid "No matching labels"
msgstr ""
......@@ -18320,6 +18320,9 @@ msgstr ""
msgid "Something went wrong while fetching projects"
msgstr ""
msgid "Something went wrong while fetching projects."
msgstr ""
msgid "Something went wrong while fetching related merge requests."
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