Commit 7b4fd170 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '119320-allow-fetching-all-projects-epics-tree' into 'master'

Support async loading & search of projects

See merge request gitlab-org/gitlab!26661
parents 94f983b8 7786e2c9
<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
import CreateIssueForm from 'ee/related_items_tree/components/create_issue_form.vue';
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 mockProjects = getJSONFixture('static/projects.json');
const projects = getJSONFixture('static/projects.json');
const createComponent = () => {
const store = createDefaultStore();
const GlDropdownStub = {
name: 'GlDropdown',
template: '<div><slot></slot></div>',
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
return shallowMount(CreateIssueForm, {
store,
});
};
describe('CreateIssueForm', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMount(CreateIssueForm, {
stubs: {
GlDropdown: GlDropdownStub,
},
propsData: {
projects,
},
beforeEach(() => {
wrapper = createComponent();
});
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);
});
};
});
const findButton = text =>
wrapper.findAll(GlButton).wrappers.find(button => button.text() === text);
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns project name with namespace when `selectedProject` is not empty', () => {
wrapper.setData({
selectedProject: mockProjects[0],
});
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.dropdownToggleText).toBe(mockProjects[0].name_with_namespace);
});
});
});
});
const getDropdownToggleText = () => wrapper.find(GlDropdownStub).attributes().text;
describe('methods', () => {
describe('cancel', () => {
it('emits event `cancel` on component', () => {
wrapper.vm.cancel();
const clickDropdownItem = index =>
findDropdownItems()
.at(index)
.vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('cancel')).toBeTruthy();
});
});
});
it('renders projects dropdown', () => {
createWrapper();
describe('createIssue', () => {
it('emits event `submit` on component when `selectedProject` is not empty', () => {
wrapper.setData({
selectedProject: {
...mockProjects[0],
_links: {
issues: 'foo',
},
},
title: 'Some issue',
});
expect(findDropdownItems().length).toBeGreaterThan(0);
expect(findDropdownItems().length).toBe(projects.length);
wrapper.vm.createIssue();
const itemTexts = findDropdownItems().wrappers.map(item => item.text());
itemTexts.forEach((text, index) => {
const project = projects[index];
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('submit')[0]).toEqual(
expect.arrayContaining([{ issuesEndpoint: 'foo', title: 'Some issue' }]),
);
});
});
});
describe('handleDropdownShow', () => {
it('sets `searchKey` prop to empty string and calls action `fetchProjects`', () => {
const handleDropdownShow = jest
.spyOn(wrapper.vm, 'fetchProjects')
.mockImplementation(jest.fn());
expect(text).toContain(project.name);
expect(text).toContain(project.namespace.name);
wrapper.vm.handleDropdownShow();
expect(wrapper.vm.searchKey).toBe('');
expect(handleDropdownShow).toHaveBeenCalled();
});
});
});
it('uses selected project as dropdown button text', () => {
createWrapper();
expect(getDropdownToggleText()).toBe('Select a project');
describe('handleDropdownHide', () => {
it('sets `searchKey` prop to empty string and calls action `fetchProjects`', () => {
const event = {
preventDefault: jest.fn(),
};
const preventDefault = jest.spyOn(event, 'preventDefault');
clickDropdownItem(1);
wrapper.setData({
preventDropdownClose: true,
});
wrapper.vm.handleDropdownHide(event);
return wrapper.vm.$nextTick().then(() => {
expect(getDropdownToggleText()).toBe(projects[1].name_with_namespace);
return wrapper.vm.$nextTick(() => {
expect(preventDefault).toHaveBeenCalled();
expect(wrapper.vm.preventDropdownClose).toBe(false);
});
});
});
});
describe('cancel button', () => {
const clickCancel = () => findButton('Cancel').vm.$emit('click');
describe('handleSearchInputContainerClick', () => {
it('sets `preventDropdownClose` to `true` when target element contains class `gl-icon`', () => {
const target = document.createElement('span');
target.setAttribute('class', 'gl-icon');
wrapper.vm.handleSearchInputContainerClick({ target });
expect(wrapper.vm.preventDropdownClose).toBe(true);
});
it('emits cancel event', () => {
createWrapper();
it('sets `preventDropdownClose` to `true` when target element href contains text `clear`', () => {
const target = document.createElement('user');
target.setAttribute('href', 'foo.svg#clear');
clickCancel();
wrapper.vm.handleSearchInputContainerClick({ target });
expect(wrapper.emitted()).toEqual({ cancel: [[]] });
expect(wrapper.vm.preventDropdownClose).toBe(true);
});
});
});
describe('submit button', () => {
const dummyTitle = 'some issue title';
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');
});
it('renders Projects dropdown field', () => {
const projectsDropdownLabel = wrapper.findAll('label').at(1);
const projectsDropdownButton = wrapper.find(GlDropdown);
const clickSubmit = () => findButton('Create issue').vm.$emit('click');
const fillTitle = title => wrapper.find(GlFormInput).vm.$emit('input', title);
expect(projectsDropdownLabel.text()).toBe('Project');
expect(projectsDropdownButton.props('text')).toBe('Select a project');
});
it('does not emit submit if project is missing', () => {
createWrapper();
fillTitle(dummyTitle);
it('renders Projects dropdown contents', () => {
wrapper.vm.$store.dispatch('receiveProjectsSuccess', mockProjects);
clickSubmit();
return wrapper.vm.$nextTick(() => {
const projectsDropdownButton = wrapper.find(GlDropdown);
const dropdownItems = projectsDropdownButton.findAll(GlDropdownItem);
expect(wrapper.emitted()).toEqual({});
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);
});
});
it('does not emit submit if title is missing', () => {
createWrapper();
clickDropdownItem(1);
it('renders Projects dropdown contents containing only matching project when searchKey is provided', () => {
const searchKey = 'Underscore';
const filteredMockProjects = mockProjects.filter(project => project.name === searchKey);
jest.spyOn(wrapper.vm, 'fetchProjects').mockImplementation(jest.fn());
wrapper.find(GlDropdown).trigger('click');
clickSubmit();
wrapper.setData({
searchKey,
});
expect(wrapper.emitted()).toEqual({});
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.vm.$store.dispatch('receiveProjectsSuccess', filteredMockProjects);
})
.then(() => {
expect(wrapper.findAll(GlDropdownItem).length).toBe(1);
});
});
it('emits submit event for filled form', () => {
createWrapper();
fillTitle(dummyTitle);
clickDropdownItem(1);
it('renders Projects dropdown contents containing string string "No matches found" when searchKey provided does not match any project', () => {
const searchKey = "this-project-shouldn't exist";
const filteredMockProjects = mockProjects.filter(project => project.name === searchKey);
jest.spyOn(wrapper.vm, 'fetchProjects').mockImplementation(jest.fn());
wrapper.find(GlDropdown).trigger('click');
wrapper.setData({
searchKey,
});
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.vm.$store.dispatch('receiveProjectsSuccess', filteredMockProjects);
})
.then(() => {
expect(wrapper.find('.dropdown-contents').text()).toContain('No matches found');
});
});
it('renders `Create issue` button', () => {
const createIssueButton = wrapper.findAll(GlButton).at(0);
expect(createIssueButton.exists()).toBe(true);
expect(createIssueButton.text()).toBe('Create issue');
});
it('renders loading icon within `Create issue` button when `itemCreateInProgress` is true', () => {
wrapper.vm.$store.dispatch('requestCreateItem');
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;
const expectedParams = [{ issuesEndpoint, title: dummyTitle }];
expect(wrapper.emitted()).toEqual({ submit: [expectedParams] });
expect(cancelButton.exists()).toBe(true);
expect(cancelButton.text()).toBe('Cancel');
});
});
});
......@@ -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,
);
});
});
});
});
});
......@@ -5633,9 +5633,6 @@ msgstr ""
msgid "Could not create group"
msgstr ""
msgid "Could not create issue"
msgstr ""
msgid "Could not create project"
msgstr ""
......@@ -5645,9 +5642,6 @@ msgstr ""
msgid "Could not delete chat nickname %{chat_name}."
msgstr ""
msgid "Could not fetch projects"
msgstr ""
msgid "Could not find design"
msgstr ""
......@@ -7843,6 +7837,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 ""
......@@ -13181,6 +13178,9 @@ msgstr ""
msgid "No licenses found."
msgstr ""
msgid "No matches found"
msgstr ""
msgid "No matching labels"
msgstr ""
......@@ -18323,6 +18323,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