Commit a873622c authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch 'boards-swimlanes/create-issue-in-list' into 'master'

Swimlanes - Add issue to list

See merge request gitlab-org/gitlab!41054
parents d4bfb315 e52b9cf3
<script>
import $ from 'jquery';
import { mapActions, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'BoardNewIssue',
......@@ -13,6 +15,7 @@ export default {
ProjectSelect,
GlButton,
},
mixins: [glFeatureFlagMixin()],
props: {
groupId: {
type: Number,
......@@ -32,6 +35,7 @@ export default {
};
},
computed: {
...mapGetters(['isSwimlanesOn']),
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
......@@ -44,6 +48,7 @@ export default {
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
...mapActions(['addListIssue', 'addListIssueFailure']),
submit(e) {
e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
......@@ -70,21 +75,31 @@ export default {
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
if (this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn) {
this.addListIssue({ list: this.list, issue, position: 0 });
}
return this.list
.newIssue(issue)
.then(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
boardsStore.setIssueDetail(issue);
boardsStore.setListDetail(this.list);
if (!this.glFeatures.boardsWithSwimlanes || !this.isSwimlanesOn) {
boardsStore.setIssueDetail(issue);
boardsStore.setListDetail(this.list);
}
})
.catch(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
// Remove the issue
this.list.removeIssue(issue);
if (this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn) {
this.addListIssueFailure({ list: this.list, issue });
} else {
this.list.removeIssue(issue);
}
// Show error message
this.error = true;
......
......@@ -235,6 +235,14 @@ export default {
notImplemented();
},
addListIssue: ({ commit }, { list, issue, position }) => {
commit(types.ADD_ISSUE_TO_LIST, { list, issue, position });
},
addListIssueFailure: ({ commit }, { list, issue }) => {
commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue });
},
fetchBacklog: () => {
notImplemented();
},
......
......@@ -15,6 +15,7 @@ import {
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '../eventhub';
import { ListType } from '../constants';
import IssueProject from '../models/project';
......@@ -303,7 +304,7 @@ const boardsStore = {
onNewListIssueResponse(list, issue, data) {
issue.refreshData(data);
if (list.issuesSize > 1) {
if (!gon.features.boardsWithSwimlanes && list.issuesSize > 1) {
const moveBeforeId = list.issues[1].id;
this.moveIssue(issue.id, null, null, null, moveBeforeId);
}
......@@ -710,6 +711,10 @@ const boardsStore = {
},
newIssue(id, issue) {
if (typeof id === 'string') {
id = getIdFromGraphQLId(id);
}
return axios.post(this.generateIssuesPath(id), {
issue,
});
......
......@@ -24,6 +24,8 @@ export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR';
export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST';
export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
......
import Vue from 'vue';
import { sortBy } from 'lodash';
import { sortBy, pull } from 'lodash';
import * as mutationTypes from './mutation_types';
import { __ } from '~/locale';
......@@ -8,6 +8,10 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
const removeIssueFromList = (state, listId, issueId) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
};
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
const { boardType, disabled, showPromotion, ...endpoints } = data;
......@@ -131,6 +135,18 @@ export default {
notImplemented();
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
const listIssues = state.issuesByListId[list.id];
listIssues.splice(position, 0, issue.id);
Vue.set(state.issuesByListId, list.id, listIssues);
Vue.set(state.issues, issue.id, issue);
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
state.error = __('An error occurred while creating the issue. Please try again.');
removeIssueFromList(state, list.id, issue.id);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
notImplemented();
},
......
......@@ -49,9 +49,15 @@ export default {
},
computed: {
...mapState(['epics', 'isLoadingIssues']),
...mapGetters(['unassignedIssues']),
...mapGetters(['getUnassignedIssues']),
unassignedIssues() {
return listId => this.getUnassignedIssues(listId);
},
unassignedIssuesCount() {
return this.lists.reduce((total, list) => total + this.unassignedIssues(list.id).length, 0);
return this.lists.reduce(
(total, list) => total + this.getUnassignedIssues(list.id).length,
0,
);
},
unassignedIssuesCountTooltipText() {
return n__(`%d unassigned issue`, `%d unassigned issues`, this.unassignedIssuesCount);
......
......@@ -12,7 +12,7 @@ export default {
return getters.getIssues(listId).filter(issue => issue.epic && issue.epic.id === epicId);
},
unassignedIssues: (state, getters) => listId => {
return getters.getIssues(listId).filter(i => i.epic === null);
getUnassignedIssues: (state, getters) => listId => {
return getters.getIssues(listId).filter(i => Boolean(i.epic) === false);
},
};
......@@ -21,18 +21,14 @@ RSpec.describe 'epics swimlanes', :js do
let_it_be(:epic_issue1) { create(:epic_issue, epic: epic1, issue: issue1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
context 'switch to swimlanes view' do
before do
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
end
before do
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
select_epics
end
context 'switch to swimlanes view' do
it 'displays epics swimlanes when selecting Epic in Group by dropdown' do
expect(page).to have_css('.board-swimlanes')
......@@ -47,8 +43,70 @@ RSpec.describe 'epics swimlanes', :js do
end
end
context 'add issue to swimlanes list' do
it 'displays new issue button' do
expect(first('.board')).to have_selector('.issue-count-badge-add-button', count: 1)
end
it 'shows form in unassigned issues lane when clicking button' do
page.within(first('.board')) do
find('.issue-count-badge-add-button').click
end
page.within("[data-testid='board-lane-unassigned-issues']") do
expect(page).to have_selector('.board-new-issue-form')
end
end
it 'hides form when clicking cancel' do
page.within(first('.board')) do
find('.issue-count-badge-add-button').click
end
page.within("[data-testid='board-lane-unassigned-issues']") do
expect(page).to have_selector('.board-new-issue-form')
click_button 'Cancel'
expect(page).not_to have_selector('.board-new-issue-form')
end
end
it 'creates new issue in unassigned issues lane' do
page.within(first('.board')) do
find('.issue-count-badge-add-button').click
end
page.within(first('.board-new-issue-form')) do
find('.form-control').set('bug')
click_button 'Submit issue'
end
wait_for_requests
page.within(first('.board .issue-count-badge-count')) do
expect(page).to have_content('3')
end
page.within("[data-testid='board-lane-unassigned-issues']") do
page.within(first('.board-card')) do
issue = project.issues.find_by_title('bug')
expect(page).to have_content(issue.to_reference)
end
end
end
end
def visit_board_page
visit project_boards_path(project)
wait_for_requests
end
def select_epics
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
end
end
......@@ -37,13 +37,12 @@ describe('EE Boards Store Getters', () => {
});
});
describe('unassignedIssues', () => {
it('returns issues for a given listId and epicId', () => {
describe('getUnassignedIssues', () => {
it('returns issues not assigned to an epic for a given listId', () => {
const getIssues = () => [mockIssue, mockIssue3, mockIssue4];
expect(getters.unassignedIssues(boardsState, { getIssues })('gid://gitlab/List/1')).toEqual([
mockIssue3,
mockIssue4,
]);
expect(
getters.getUnassignedIssues(boardsState, { getIssues })('gid://gitlab/List/1'),
).toEqual([mockIssue3, mockIssue4]);
});
});
});
......@@ -2627,6 +2627,9 @@ msgstr ""
msgid "An error occurred while committing your changes."
msgstr ""
msgid "An error occurred while creating the issue. Please try again."
msgstr ""
msgid "An error occurred while creating the list. Please try again."
msgstr ""
......
......@@ -312,7 +312,7 @@ describe('boardsStore', () => {
});
describe('newIssue', () => {
const id = 'not-creative';
const id = 1;
const issue = { some: 'issue data' };
const url = `${endpoints.listsEndpoint}/${id}/issues`;
const expectedRequest = expect.objectContaining({
......
......@@ -184,6 +184,7 @@ describe('List model', () => {
}),
);
list.issues = [];
global.gon.features = { boardsWithSwimlanes: false };
});
it('adds new issue to top of list', done => {
......
......@@ -117,6 +117,29 @@ export const mockIssue = {
],
};
export const mockIssue2 = {
title: 'Planning',
id: 2,
iid: 2,
confidential: false,
labels: [
{
id: 1,
title: 'plan',
color: 'blue',
description: 'planning',
},
],
assignees: [
{
id: 1,
name: 'name',
username: 'username',
avatar_url: 'http://avatar_url',
},
],
};
export const BoardsMockData = {
GET: {
'/test/-/boards/1/lists/300/issues?id=300&page=1': {
......
import testAction from 'helpers/vuex_action_helper';
import { mockListsWithModel } from '../mock_data';
import { mockListsWithModel, mockLists, mockIssue } from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId, ListType } from '~/boards/constants';
......@@ -236,6 +236,43 @@ describe('createNewIssue', () => {
expectNotImplemented(actions.createNewIssue);
});
describe('addListIssue', () => {
it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
const payload = {
list: mockLists[0],
issue: mockIssue,
position: 0,
};
testAction(
actions.addListIssue,
payload,
{},
[{ type: types.ADD_ISSUE_TO_LIST, payload }],
[],
done,
);
});
});
describe('addListIssueFailure', () => {
it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
const payload = {
list: mockLists[0],
issue: mockIssue,
};
testAction(
actions.addListIssueFailure,
payload,
{},
[{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }],
[],
done,
);
});
});
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
......
import mutations from '~/boards/stores/mutations';
import * as types from '~/boards/stores/mutation_types';
import defaultState from '~/boards/stores/state';
import { listObj, listObjDuplicate, mockIssue, mockListsWithModel } from '../mock_data';
import {
listObj,
listObjDuplicate,
mockIssue,
mockIssue2,
mockListsWithModel,
mockLists,
} from '../mock_data';
const expectNotImplemented = action => {
it('is not implemented', () => {
......@@ -148,7 +155,7 @@ describe('Board Store Mutations', () => {
describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => {
it('sets isLoadingIssues to false and updates issuesByListId object', () => {
const listIssues = {
'1': [mockIssue.id],
'': [mockIssue.id],
};
const issues = {
'1': mockIssue,
......@@ -264,6 +271,50 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR);
});
describe('ADD_ISSUE_TO_LIST', () => {
it('adds issue to issues state and issue id in list in issuesByListId', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
};
const issues = {
'1': mockIssue,
};
state = {
...state,
issuesByListId: listIssues,
issues,
};
mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 });
expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
expect(state.issues[mockIssue2.id]).toEqual(mockIssue2);
});
});
describe('ADD_ISSUE_TO_LIST_FAILURE', () => {
it('removes issue id from list in issuesByListId', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
const issues = {
'1': mockIssue,
'2': mockIssue2,
};
state = {
...state,
issuesByListId: listIssues,
issues,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
});
});
describe('SET_CURRENT_PAGE', () => {
expectNotImplemented(mutations.SET_CURRENT_PAGE);
});
......
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