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> <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 { __ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { SEARCH_DEBOUNCE } from '../constants';
export default { export default {
components: { components: {
...@@ -11,24 +20,20 @@ export default { ...@@ -11,24 +20,20 @@ export default {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlFormInput, GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
ProjectAvatar, ProjectAvatar,
}, },
props: {
projects: {
type: Array,
required: true,
},
},
data() { data() {
return { return {
selectedProject: null, selectedProject: null,
searchKey: '',
title: '', title: '',
preventDropdownClose: false,
}; };
}, },
computed: { computed: {
...mapState(['projectsFetchInProgress', 'itemCreateInProgress', 'projects']),
dropdownToggleText() { dropdownToggleText() {
if (this.selectedProject) { if (this.selectedProject) {
return this.selectedProject.name_with_namespace; return this.selectedProject.name_with_namespace;
...@@ -36,19 +41,37 @@ export default { ...@@ -36,19 +41,37 @@ export default {
return __('Select a project'); return __('Select a project');
}, },
},
hasValidInput() { watch: {
return this.title.trim() !== '' && this.selectedProject; /**
* 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: { methods: {
...mapActions(['fetchProjects']),
cancel() { cancel() {
this.$emit('cancel'); this.$emit('cancel');
}, },
createIssue() { createIssue() {
if (!this.hasValidInput) { if (!this.selectedProject) {
return; return;
} }
...@@ -56,6 +79,36 @@ export default { ...@@ -56,6 +79,36 @@ export default {
const { issues: issuesEndpoint } = selectedProject._links; const { issues: issuesEndpoint } = selectedProject._links;
this.$emit('submit', { issuesEndpoint, title }); 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> </script>
...@@ -67,7 +120,7 @@ export default { ...@@ -67,7 +120,7 @@ export default {
<label class="label-bold">{{ s__('Issue|Title') }}</label> <label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input <gl-form-input
ref="titleInput" ref="titleInput"
v-model="title" v-model.trim="title"
:placeholder="__('New issue title')" :placeholder="__('New issue title')"
autofocus autofocus
/> />
...@@ -75,11 +128,30 @@ export default { ...@@ -75,11 +128,30 @@ export default {
<div class="col-sm"> <div class="col-sm">
<label class="label-bold">{{ __('Project') }}</label> <label class="label-bold">{{ __('Project') }}</label>
<gl-dropdown <gl-dropdown
ref="dropdownButton"
:text="dropdownToggleText" :text="dropdownToggleText"
class="w-100" class="w-100 projects-dropdown"
menu-class="w-100" menu-class="w-100 overflow-hidden"
toggle-class="d-flex align-items-center justify-content-between text-truncate" toggle-class="d-flex align-items-center justify-content-between text-truncate"
@show="handleDropdownShow"
@hide="handleDropdownHide"
> >
<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 <gl-dropdown-item
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
...@@ -90,15 +162,21 @@ export default { ...@@ -90,15 +162,21 @@ export default {
{{ project.name }} {{ project.name }}
<div class="text-secondary">{{ project.namespace.name }}</div> <div class="text-secondary">{{ project.namespace.name }}</div>
</gl-dropdown-item> </gl-dropdown-item>
</div>
</gl-dropdown> </gl-dropdown>
</div> </div>
</div> </div>
<div class="row my-1"> <div class="row my-1">
<div class="col-sm flex-sm-grow-0 mb-2 mb-sm-0"> <div class="col-sm flex-sm-grow-0 mb-2 mb-sm-0">
<gl-button class="w-100" variant="success" :disabled="!hasValidInput" @click="createIssue"> <gl-button
{{ __('Create issue') }} class="w-100"
</gl-button> variant="success"
:disabled="!selectedProject || itemCreateInProgress"
:loading="itemCreateInProgress"
@click="createIssue"
>{{ __('Create issue') }}</gl-button
>
</div> </div>
<div class="col-sm flex-sm-grow-0 ml-auto"> <div class="col-sm flex-sm-grow-0 ml-auto">
<gl-button class="w-100" @click="cancel">{{ __('Cancel') }}</gl-button> <gl-button class="w-100" @click="cancel">{{ __('Cancel') }}</gl-button>
......
...@@ -37,11 +37,6 @@ export default { ...@@ -37,11 +37,6 @@ export default {
IssueActionsSplitButton, IssueActionsSplitButton,
SlotSwitch, SlotSwitch,
}, },
data() {
return {
isCreateIssueFormVisible: false,
};
},
computed: { computed: {
...mapState([ ...mapState([
'parentItem', 'parentItem',
...@@ -54,6 +49,7 @@ export default { ...@@ -54,6 +49,7 @@ export default {
'itemCreateInProgress', 'itemCreateInProgress',
'showAddItemForm', 'showAddItemForm',
'showCreateEpicForm', 'showCreateEpicForm',
'showCreateIssueForm',
'autoCompleteEpics', 'autoCompleteEpics',
'autoCompleteIssues', 'autoCompleteIssues',
'pendingReferences', 'pendingReferences',
...@@ -61,7 +57,6 @@ export default { ...@@ -61,7 +57,6 @@ export default {
'issuableType', 'issuableType',
'epicsEndpoint', 'epicsEndpoint',
'issuesEndpoint', 'issuesEndpoint',
'projects',
]), ]),
...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']), ...mapGetters(['itemAutoCompleteSources', 'itemPathIdSeparator', 'directChildren']),
disableContents() { disableContents() {
...@@ -76,7 +71,7 @@ export default { ...@@ -76,7 +71,7 @@ export default {
return FORM_SLOTS.createEpic; return FORM_SLOTS.createEpic;
} }
if (this.isCreateIssueFormVisible) { if (this.showCreateIssueForm) {
return FORM_SLOTS.createIssue; return FORM_SLOTS.createIssue;
} }
...@@ -93,6 +88,7 @@ export default { ...@@ -93,6 +88,7 @@ export default {
'fetchItems', 'fetchItems',
'toggleAddItemForm', 'toggleAddItemForm',
'toggleCreateEpicForm', 'toggleCreateEpicForm',
'toggleCreateIssueForm',
'setPendingReferences', 'setPendingReferences',
'addPendingReferences', 'addPendingReferences',
'removePendingReference', 'removePendingReference',
...@@ -137,15 +133,11 @@ export default { ...@@ -137,15 +133,11 @@ export default {
this.toggleCreateEpicForm({ toggleState: false }); this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue(''); this.setItemInputValue('');
}, },
showAddIssueForm() { handleShowAddIssueForm() {
this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE }); this.toggleAddItemForm({ toggleState: true, issuableType: issuableTypesMap.ISSUE });
}, },
showCreateIssueForm() { handleShowCreateIssueForm() {
return this.fetchProjects().then(() => { this.toggleCreateIssueForm({ toggleState: true });
this.toggleAddItemForm({ toggleState: false });
this.toggleCreateEpicForm({ toggleState: false });
this.isCreateIssueFormVisible = true;
});
}, },
}, },
}; };
...@@ -168,8 +160,8 @@ export default { ...@@ -168,8 +160,8 @@ export default {
<issue-actions-split-button <issue-actions-split-button
slot="issueActions" slot="issueActions"
class="ml-1" class="ml-1"
@showAddIssueForm="showAddIssueForm" @showAddIssueForm="handleShowAddIssueForm"
@showCreateIssueForm="showCreateIssueForm" @showCreateIssueForm="handleShowCreateIssueForm"
/> />
</related-items-tree-header> </related-items-tree-header>
<slot-switch <slot-switch
...@@ -206,8 +198,7 @@ export default { ...@@ -206,8 +198,7 @@ export default {
/> />
<create-issue-form <create-issue-form
:slot="$options.FORM_SLOTS.createIssue" :slot="$options.FORM_SLOTS.createIssue"
:projects="projects" @cancel="toggleCreateIssueForm({ toggleState: false })"
@cancel="isCreateIssueFormVisible = false"
@submit="createNewIssue" @submit="createNewIssue"
/> />
</slot-switch> </slot-switch>
......
...@@ -39,4 +39,6 @@ export const RemoveItemModalProps = { ...@@ -39,4 +39,6 @@ export const RemoveItemModalProps = {
export const OVERFLOW_AFTER = 5; export const OVERFLOW_AFTER = 5;
export const SEARCH_DEBOUNCE = 500;
export const itemRemoveModalId = 'item-remove-confirmation'; export const itemRemoveModalId = 'item-remove-confirmation';
...@@ -252,6 +252,8 @@ export const removeItem = ({ dispatch }, { parentItem, item }) => { ...@@ -252,6 +252,8 @@ export const removeItem = ({ dispatch }, { parentItem, item }) => {
export const toggleAddItemForm = ({ commit }, data) => commit(types.TOGGLE_ADD_ITEM_FORM, data); export const toggleAddItemForm = ({ commit }, data) => commit(types.TOGGLE_ADD_ITEM_FORM, data);
export const toggleCreateEpicForm = ({ commit }, data) => export const toggleCreateEpicForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_EPIC_FORM, 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) => export const setPendingReferences = ({ commit }, data) =>
commit(types.SET_PENDING_REFERENCES, data); commit(types.SET_PENDING_REFERENCES, data);
...@@ -448,42 +450,62 @@ export const reorderItem = ( ...@@ -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 }) => { export const createNewIssue = ({ state, dispatch }, { issuesEndpoint, title }) => {
const { parentItem } = state; const { parentItem } = state;
// necessary because parentItem comes from GraphQL and we are using REST API here // necessary because parentItem comes from GraphQL and we are using REST API here
const epicId = parseInt(parentItem.id.replace(/^gid:\/\/gitlab\/Epic\//, ''), 10); const epicId = parseInt(parentItem.id.replace(/^gid:\/\/gitlab\/Epic\//, ''), 10);
dispatch('requestCreateItem');
return axios return axios
.post(issuesEndpoint, { epic_id: epicId, title }) .post(issuesEndpoint, { epic_id: epicId, title })
.then(() => .then(({ data }) => {
dispatch('receiveCreateIssueSuccess', data);
dispatch('fetchItems', { dispatch('fetchItems', {
parentItem, parentItem,
}), });
) })
.catch(e => { .catch(e => {
flash(__('Could not create issue')); dispatch('receiveCreateIssueFailure');
throw e; throw e;
}); });
}; };
export const fetchProjects = ({ state, commit }) => export const requestProjects = ({ commit }) => commit(types.REQUEST_PROJECTS);
axios export const receiveProjectsSuccess = ({ commit }, data) =>
.get(state.projectsEndpoint, { commit(types.RECIEVE_PROJECTS_SUCCESS, data);
params: { 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, include_subgroups: true,
order_by: 'last_activity_at', order_by: 'last_activity_at',
with_issues_enabled: true, with_issues_enabled: true,
with_shared: false, with_shared: false,
}, };
if (searchKey) {
params.search = searchKey;
}
dispatch('requestProjects');
axios
.get(state.projectsEndpoint, {
params,
}) })
.then(({ data }) => { .then(({ data }) => {
commit(types.SET_PROJECTS, data); dispatch('receiveProjectsSuccess', data);
}) })
.catch(e => { .catch(() => dispatch('receiveProjectsFailure'));
flash(__('Could not fetch projects')); };
throw e;
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -22,6 +22,7 @@ export const COLLAPSE_ITEM = 'COLLAPSE_ITEM'; ...@@ -22,6 +22,7 @@ export const COLLAPSE_ITEM = 'COLLAPSE_ITEM';
export const TOGGLE_ADD_ITEM_FORM = 'TOGGLE_ADD_ITEM_FORM'; export const TOGGLE_ADD_ITEM_FORM = 'TOGGLE_ADD_ITEM_FORM';
export const TOGGLE_CREATE_EPIC_FORM = 'TOGGLE_CREATE_EPIC_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 SET_PENDING_REFERENCES = 'SET_PENDING_REFERENCES';
export const ADD_PENDING_REFERENCES = 'ADD_PENDING_REFERENCES'; export const ADD_PENDING_REFERENCES = 'ADD_PENDING_REFERENCES';
...@@ -40,3 +41,6 @@ export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE'; ...@@ -40,3 +41,6 @@ export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE';
export const REORDER_ITEM = 'REORDER_ITEM'; export const REORDER_ITEM = 'REORDER_ITEM';
export const SET_PROJECTS = 'SET_PROJECTS'; 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 { ...@@ -144,6 +144,11 @@ export default {
state.showAddItemForm = false; state.showAddItemForm = false;
}, },
[types.TOGGLE_CREATE_ISSUE_FORM](state, { toggleState }) {
state.showCreateIssueForm = toggleState;
state.showAddItemForm = false;
},
[types.SET_PENDING_REFERENCES](state, references) { [types.SET_PENDING_REFERENCES](state, references) {
state.pendingReferences = references; state.pendingReferences = references;
}, },
...@@ -203,7 +208,14 @@ export default { ...@@ -203,7 +208,14 @@ export default {
state.children[parentItem.reference].splice(newIndex, 0, targetItem); 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.projects = projects;
state.projectsFetchInProgress = false;
},
[types.RECIEVE_PROJECTS_FAILURE](state) {
state.projectsFetchInProgress = false;
}, },
}; };
...@@ -32,8 +32,10 @@ export default () => ({ ...@@ -32,8 +32,10 @@ export default () => ({
itemAddInProgress: false, itemAddInProgress: false,
itemAddFailure: false, itemAddFailure: false,
itemCreateInProgress: false, itemCreateInProgress: false,
projectsFetchInProgress: false,
showAddItemForm: false, showAddItemForm: false,
showCreateEpicForm: false, showCreateEpicForm: false,
showCreateIssueForm: false,
autoCompleteEpics: false, autoCompleteEpics: false,
autoCompleteIssues: false, autoCompleteIssues: false,
allowSubEpics: false, allowSubEpics: false,
......
...@@ -4,6 +4,10 @@ ...@@ -4,6 +4,10 @@
.add-item-form-container { .add-item-form-container {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
.projects-dropdown .dropdown-contents {
max-height: $dropdown-max-height - 50;
}
} }
.sub-tree-root { .sub-tree-root {
......
---
title: Support async loading & search of projects within Epics Tree
merge_request: 26661
author:
type: added
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton, GlDropdownItem, GlFormInput } from '@gitlab/ui'; import {
GlButton,
GlDropdown,
GlDropdownItem,
GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
import createDefaultStore from 'ee/related_items_tree/store';
// https://gitlab.com/gitlab-org/gitlab/issues/118456
import {
mockInitialConfig,
mockParentItem,
} from '../../../javascripts/related_items_tree/mock_data';
const projects = getJSONFixture('static/projects.json'); const mockProjects = getJSONFixture('static/projects.json');
const GlDropdownStub = { const createComponent = () => {
name: 'GlDropdown', const store = createDefaultStore();
template: '<div><slot></slot></div>',
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
return shallowMount(CreateIssueForm, {
store,
});
}; };
describe('CreateIssueForm', () => { describe('CreateIssueForm', () => {
let wrapper; let wrapper;
const createWrapper = () => { beforeEach(() => {
wrapper = shallowMount(CreateIssueForm, { wrapper = createComponent();
stubs: { });
GlDropdown: GlDropdownStub,
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('initializes data props with default values', () => {
expect(wrapper.vm.selectedProject).toBeNull();
expect(wrapper.vm.searchKey).toBe('');
expect(wrapper.vm.title).toBe('');
expect(wrapper.vm.preventDropdownClose).toBe(false);
});
});
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns project name with namespace when `selectedProject` is not empty', () => {
wrapper.setData({
selectedProject: mockProjects[0],
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.dropdownToggleText).toBe(mockProjects[0].name_with_namespace);
});
});
});
});
describe('methods', () => {
describe('cancel', () => {
it('emits event `cancel` on component', () => {
wrapper.vm.cancel();
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('cancel')).toBeTruthy();
});
});
});
describe('createIssue', () => {
it('emits event `submit` on component when `selectedProject` is not empty', () => {
wrapper.setData({
selectedProject: {
...mockProjects[0],
_links: {
issues: 'foo',
}, },
propsData: {
projects,
}, },
title: 'Some issue',
}); });
};
const findButton = text => wrapper.vm.createIssue();
wrapper.findAll(GlButton).wrappers.find(button => button.text() === text);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem); return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('submit')[0]).toEqual(
expect.arrayContaining([{ issuesEndpoint: 'foo', title: 'Some issue' }]),
);
});
});
});
const getDropdownToggleText = () => wrapper.find(GlDropdownStub).attributes().text; describe('handleDropdownShow', () => {
it('sets `searchKey` prop to empty string and calls action `fetchProjects`', () => {
const handleDropdownShow = jest
.spyOn(wrapper.vm, 'fetchProjects')
.mockImplementation(jest.fn());
const clickDropdownItem = index => wrapper.vm.handleDropdownShow();
findDropdownItems()
.at(index)
.vm.$emit('click');
it('renders projects dropdown', () => { expect(wrapper.vm.searchKey).toBe('');
createWrapper(); expect(handleDropdownShow).toHaveBeenCalled();
});
});
expect(findDropdownItems().length).toBeGreaterThan(0); describe('handleDropdownHide', () => {
expect(findDropdownItems().length).toBe(projects.length); it('sets `searchKey` prop to empty string and calls action `fetchProjects`', () => {
const event = {
preventDefault: jest.fn(),
};
const preventDefault = jest.spyOn(event, 'preventDefault');
const itemTexts = findDropdownItems().wrappers.map(item => item.text()); wrapper.setData({
itemTexts.forEach((text, index) => { preventDropdownClose: true,
const project = projects[index]; });
wrapper.vm.handleDropdownHide(event);
expect(text).toContain(project.name); return wrapper.vm.$nextTick(() => {
expect(text).toContain(project.namespace.name); expect(preventDefault).toHaveBeenCalled();
expect(wrapper.vm.preventDropdownClose).toBe(false);
});
}); });
}); });
it('uses selected project as dropdown button text', () => { describe('handleSearchInputContainerClick', () => {
createWrapper(); it('sets `preventDropdownClose` to `true` when target element contains class `gl-icon`', () => {
expect(getDropdownToggleText()).toBe('Select a project'); const target = document.createElement('span');
target.setAttribute('class', 'gl-icon');
clickDropdownItem(1); wrapper.vm.handleSearchInputContainerClick({ target });
return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.preventDropdownClose).toBe(true);
expect(getDropdownToggleText()).toBe(projects[1].name_with_namespace);
}); });
it('sets `preventDropdownClose` to `true` when target element href contains text `clear`', () => {
const target = document.createElement('user');
target.setAttribute('href', 'foo.svg#clear');
wrapper.vm.handleSearchInputContainerClick({ target });
expect(wrapper.vm.preventDropdownClose).toBe(true);
});
});
});
describe('templates', () => {
it('renders Issue title input field', () => {
const issueTitleFieldLabel = wrapper.findAll('label').at(0);
const issueTitleFieldInput = wrapper.find(GlFormInput);
expect(issueTitleFieldLabel.text()).toBe('Title');
expect(issueTitleFieldInput.attributes('placeholder')).toBe('New issue title');
}); });
describe('cancel button', () => { it('renders Projects dropdown field', () => {
const clickCancel = () => findButton('Cancel').vm.$emit('click'); const projectsDropdownLabel = wrapper.findAll('label').at(1);
const projectsDropdownButton = wrapper.find(GlDropdown);
expect(projectsDropdownLabel.text()).toBe('Project');
expect(projectsDropdownButton.props('text')).toBe('Select a project');
});
it('emits cancel event', () => { it('renders Projects dropdown contents', () => {
createWrapper(); wrapper.vm.$store.dispatch('receiveProjectsSuccess', mockProjects);
clickCancel(); return wrapper.vm.$nextTick(() => {
const projectsDropdownButton = wrapper.find(GlDropdown);
const dropdownItems = projectsDropdownButton.findAll(GlDropdownItem);
expect(wrapper.emitted()).toEqual({ cancel: [[]] }); expect(projectsDropdownButton.find(GlSearchBoxByType).exists()).toBe(true);
expect(projectsDropdownButton.find(GlLoadingIcon).exists()).toBe(true);
expect(dropdownItems.length).toBe(mockProjects.length);
expect(dropdownItems.at(0).text()).toContain(mockProjects[0].name);
expect(dropdownItems.at(0).text()).toContain(mockProjects[0].namespace.name);
expect(
dropdownItems
.at(0)
.find(ProjectAvatar)
.exists(),
).toBe(true);
}); });
}); });
describe('submit button', () => { it('renders Projects dropdown contents containing only matching project when searchKey is provided', () => {
const dummyTitle = 'some issue title'; const searchKey = 'Underscore';
const filteredMockProjects = mockProjects.filter(project => project.name === searchKey);
jest.spyOn(wrapper.vm, 'fetchProjects').mockImplementation(jest.fn());
wrapper.find(GlDropdown).trigger('click');
const clickSubmit = () => findButton('Create issue').vm.$emit('click'); wrapper.setData({
const fillTitle = title => wrapper.find(GlFormInput).vm.$emit('input', title); searchKey,
});
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.vm.$store.dispatch('receiveProjectsSuccess', filteredMockProjects);
})
.then(() => {
expect(wrapper.findAll(GlDropdownItem).length).toBe(1);
});
});
it('does not emit submit if project is missing', () => { it('renders Projects dropdown contents containing string string "No matches found" when searchKey provided does not match any project', () => {
createWrapper(); const searchKey = "this-project-shouldn't exist";
fillTitle(dummyTitle); const filteredMockProjects = mockProjects.filter(project => project.name === searchKey);
jest.spyOn(wrapper.vm, 'fetchProjects').mockImplementation(jest.fn());
clickSubmit(); wrapper.find(GlDropdown).trigger('click');
expect(wrapper.emitted()).toEqual({}); wrapper.setData({
searchKey,
}); });
it('does not emit submit if title is missing', () => { return wrapper.vm
createWrapper(); .$nextTick()
clickDropdownItem(1); .then(() => {
wrapper.vm.$store.dispatch('receiveProjectsSuccess', filteredMockProjects);
})
.then(() => {
expect(wrapper.find('.dropdown-contents').text()).toContain('No matches found');
});
});
clickSubmit(); it('renders `Create issue` button', () => {
const createIssueButton = wrapper.findAll(GlButton).at(0);
expect(wrapper.emitted()).toEqual({}); expect(createIssueButton.exists()).toBe(true);
expect(createIssueButton.text()).toBe('Create issue');
}); });
it('emits submit event for filled form', () => { it('renders loading icon within `Create issue` button when `itemCreateInProgress` is true', () => {
createWrapper(); wrapper.vm.$store.dispatch('requestCreateItem');
fillTitle(dummyTitle);
clickDropdownItem(1); return wrapper.vm.$nextTick(() => {
const createIssueButton = wrapper.findAll(GlButton).at(0);
expect(createIssueButton.exists()).toBe(true);
expect(createIssueButton.props('disabled')).toBe(true);
expect(createIssueButton.props('loading')).toBe(true);
});
});
clickSubmit(); it('renders `Cancel` button', () => {
const cancelButton = wrapper.findAll(GlButton).at(1);
const issuesEndpoint = projects[1]._links.issues; expect(cancelButton.exists()).toBe(true);
const expectedParams = [{ issuesEndpoint, title: dummyTitle }]; expect(cancelButton.text()).toBe('Cancel');
expect(wrapper.emitted()).toEqual({ submit: [expectedParams] });
}); });
}); });
}); });
...@@ -17,6 +17,8 @@ import { getJSONFixture } from 'helpers/fixtures'; ...@@ -17,6 +17,8 @@ import { getJSONFixture } from 'helpers/fixtures';
import { import {
mockInitialConfig, mockInitialConfig,
mockParentItem, mockParentItem,
mockEpics,
mockIssues,
} from '../../../javascripts/related_items_tree/mock_data'; } from '../../../javascripts/related_items_tree/mock_data';
const mockProjects = getJSONFixture('static/projects.json'); const mockProjects = getJSONFixture('static/projects.json');
...@@ -26,6 +28,10 @@ const createComponent = () => { ...@@ -26,6 +28,10 @@ const createComponent = () => {
store.dispatch('setInitialConfig', mockInitialConfig); store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem); store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
children: [...mockEpics, ...mockIssues],
});
return shallowMount(RelatedItemsTreeApp, { return shallowMount(RelatedItemsTreeApp, {
store, store,
...@@ -41,7 +47,6 @@ describe('RelatedItemsTreeApp', () => { ...@@ -41,7 +47,6 @@ describe('RelatedItemsTreeApp', () => {
const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton); const findIssueActionsSplitButton = () => wrapper.find(IssueActionsSplitButton);
const showCreateIssueForm = () => { const showCreateIssueForm = () => {
findIssueActionsSplitButton().vm.$emit('showCreateIssueForm'); findIssueActionsSplitButton().vm.$emit('showCreateIssueForm');
return axios.waitFor(mockInitialConfig.projectsEndpoint).then(() => wrapper.vm.$nextTick());
}; };
beforeEach(() => { beforeEach(() => {
...@@ -273,11 +278,10 @@ describe('RelatedItemsTreeApp', () => { ...@@ -273,11 +278,10 @@ describe('RelatedItemsTreeApp', () => {
it('shows create item form', () => { it('shows create item form', () => {
expect(findCreateIssueForm().exists()).toBe(false); expect(findCreateIssueForm().exists()).toBe(false);
return showCreateIssueForm().then(() => { showCreateIssueForm();
const form = findCreateIssueForm();
expect(form.exists()).toBe(true); return wrapper.vm.$nextTick(() => {
expect(form.props().projects).toBe(mockProjects); expect(findCreateIssueForm().exists()).toBe(true);
}); });
}); });
}); });
......
...@@ -392,6 +392,17 @@ describe('RelatedItemsTree', () => { ...@@ -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, () => { describe(types.SET_PENDING_REFERENCES, () => {
it('should set `pendingReferences` to state based on provided `references` param', () => { it('should set `pendingReferences` to state based on provided `references` param', () => {
const reference = ['foo']; const reference = ['foo'];
...@@ -546,6 +557,33 @@ describe('RelatedItemsTree', () => { ...@@ -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 { ...@@ -27,6 +27,8 @@ import {
mockEpic1, mockEpic1,
} from '../mock_data'; } from '../mock_data';
const mockProjects = getJSONFixture('static/projects.json');
describe('RelatedItemTree', () => { describe('RelatedItemTree', () => {
describe('store', () => { describe('store', () => {
describe('actions', () => { describe('actions', () => {
...@@ -801,6 +803,19 @@ describe('RelatedItemTree', () => { ...@@ -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', () => { describe('setPendingReferences', () => {
it('should set param value to `state.pendingReference`', done => { it('should set param value to `state.pendingReference`', done => {
testAction( testAction(
...@@ -1325,6 +1340,52 @@ describe('RelatedItemTree', () => { ...@@ -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', () => { describe('createNewIssue', () => {
const issuesEndpoint = `${TEST_HOST}/issues`; const issuesEndpoint = `${TEST_HOST}/issues`;
const title = 'new issue title'; const title = 'new issue title';
...@@ -1382,6 +1443,8 @@ describe('RelatedItemTree', () => { ...@@ -1382,6 +1443,8 @@ describe('RelatedItemTree', () => {
.createNewIssue(context, payload) .createNewIssue(context, payload)
.then(() => { .then(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest); expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).toHaveBeenCalledWith('requestCreateItem');
expect(context.dispatch).toHaveBeenCalledWith('receiveCreateIssueSuccess', '');
expect(context.dispatch).toHaveBeenCalledWith( expect(context.dispatch).toHaveBeenCalledWith(
'fetchItems', 'fetchItems',
jasmine.objectContaining({ parentItem }), jasmine.objectContaining({ parentItem }),
...@@ -1405,14 +1468,120 @@ describe('RelatedItemTree', () => { ...@@ -1405,14 +1468,120 @@ describe('RelatedItemTree', () => {
.then(() => done.fail('expected action to throw error!')) .then(() => done.fail('expected action to throw error!'))
.catch(() => { .catch(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest); expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
expect(context.dispatch).not.toHaveBeenCalled(); expect(context.dispatch).toHaveBeenCalledWith('receiveCreateIssueFailure');
expect(flashSpy).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .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 "" ...@@ -5630,9 +5630,6 @@ msgstr ""
msgid "Could not create group" msgid "Could not create group"
msgstr "" msgstr ""
msgid "Could not create issue"
msgstr ""
msgid "Could not create project" msgid "Could not create project"
msgstr "" msgstr ""
...@@ -5642,9 +5639,6 @@ msgstr "" ...@@ -5642,9 +5639,6 @@ msgstr ""
msgid "Could not delete chat nickname %{chat_name}." msgid "Could not delete chat nickname %{chat_name}."
msgstr "" msgstr ""
msgid "Could not fetch projects"
msgstr ""
msgid "Could not find design" msgid "Could not find design"
msgstr "" msgstr ""
...@@ -7840,6 +7834,9 @@ msgstr "" ...@@ -7840,6 +7834,9 @@ msgstr ""
msgid "Epics|Something went wrong while creating child epics." msgid "Epics|Something went wrong while creating child epics."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while creating issue."
msgstr ""
msgid "Epics|Something went wrong while fetching child epics." msgid "Epics|Something went wrong while fetching child epics."
msgstr "" msgstr ""
...@@ -13178,6 +13175,9 @@ msgstr "" ...@@ -13178,6 +13175,9 @@ msgstr ""
msgid "No licenses found." msgid "No licenses found."
msgstr "" msgstr ""
msgid "No matches found"
msgstr ""
msgid "No matching labels" msgid "No matching labels"
msgstr "" msgstr ""
...@@ -18320,6 +18320,9 @@ msgstr "" ...@@ -18320,6 +18320,9 @@ msgstr ""
msgid "Something went wrong while fetching projects" msgid "Something went wrong while fetching projects"
msgstr "" msgstr ""
msgid "Something went wrong while fetching projects."
msgstr ""
msgid "Something went wrong while fetching related merge requests." msgid "Something went wrong while fetching related merge requests."
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