Commit 1de805ca authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Kushal Pandya

Add methods for updating the epic associated with an issue

Add action to dispatch updateEpicIssue action in boards_store

Make boards_store update run only within the board component

Move epic-related methods to ee

Move all methods that update the epic-
associated with an issue to the ee counterparts per-
"Separation of EE specific content".

Add test for updating epic

Move methods to sub-components

Move the methods in issue and boards_store_ee.js -
to action.js for an easier reference later.

Add test for updateEpic

Add vuex methods for selectedEpicIssueId

This fixes the problem of removing an assigned epic.
Previously, selectedEpicIssueId wasn't updated after -
a render raising an error during Api.removeEpicIssue call.
A unit test is also added to test mutations.
parent 0a98de1f
import ListIssue from '~/boards/models/issue'; import ListIssue from '~/boards/models/issue';
import IssueProject from '~/boards/models/project'; import IssueProject from '~/boards/models/project';
import boardsStore from '~/boards/stores/boards_store';
class ListIssueEE extends ListIssue { class ListIssueEE extends ListIssue {
constructor(obj) { constructor(obj) {
...@@ -13,6 +14,10 @@ class ListIssueEE extends ListIssue { ...@@ -13,6 +14,10 @@ class ListIssueEE extends ListIssue {
this.project = new IssueProject(obj.project); this.project = new IssueProject(obj.project);
} }
} }
updateEpic(newEpic) {
boardsStore.updateIssueEpic(this, newEpic);
}
} }
window.ListIssue = ListIssueEE; window.ListIssue = ListIssueEE;
......
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this, no-param-reassign */
/*
no-param-reassign is disabled because one method of BoardsStoreEE
modify the passed parameter in conformity with non-ee BoardsStore.
*/
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
...@@ -68,6 +73,8 @@ class BoardsStoreEE { ...@@ -68,6 +73,8 @@ class BoardsStoreEE {
} }
}; };
this.store.updateIssueEpic = this.updateIssueEpic;
sidebarEventHub.$on('updateWeight', this.updateWeight.bind(this)); sidebarEventHub.$on('updateWeight', this.updateWeight.bind(this));
Object.assign(this.store, { Object.assign(this.store, {
...@@ -179,6 +186,10 @@ class BoardsStoreEE { ...@@ -179,6 +186,10 @@ class BoardsStoreEE {
this.store.findList('id', id).maxIssueCount = maxIssueCount; this.store.findList('id', id).maxIssueCount = maxIssueCount;
} }
updateIssueEpic(issue, newEpic) {
issue.epic = newEpic;
}
updateWeight(newWeight, id) { updateWeight(newWeight, id) {
const { issue } = this.store.detail; const { issue } = this.store.detail;
if (issue.id === id && issue.sidebarInfoEndpoint) { if (issue.id === id && issue.sidebarInfoEndpoint) {
......
...@@ -74,7 +74,13 @@ export default { ...@@ -74,7 +74,13 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['epicSelectInProgress', 'epicsFetchInProgress', 'selectedEpic', 'searchQuery']), ...mapState([
'epicSelectInProgress',
'epicsFetchInProgress',
'selectedEpic',
'searchQuery',
'selectedEpicIssueId',
]),
...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone', 'groupEpics']), ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone', 'groupEpics']),
dropdownSelectInProgress() { dropdownSelectInProgress() {
return this.initialEpicLoading || this.epicSelectInProgress; return this.initialEpicLoading || this.epicSelectInProgress;
...@@ -99,6 +105,7 @@ export default { ...@@ -99,6 +105,7 @@ export default {
*/ */
initialEpic() { initialEpic() {
this.setSelectedEpic(this.initialEpic); this.setSelectedEpic(this.initialEpic);
this.setSelectedEpicIssueId(this.epicIssueId);
}, },
/** /**
* Initial Epic is loaded via separate Sidebar store * Initial Epic is loaded via separate Sidebar store
...@@ -106,6 +113,7 @@ export default { ...@@ -106,6 +113,7 @@ export default {
*/ */
initialEpicLoading() { initialEpicLoading() {
this.setSelectedEpic(this.initialEpic); this.setSelectedEpic(this.initialEpic);
this.setSelectedEpicIssueId(this.epicIssueId);
}, },
/** /**
* Check if `searchQuery` presence has yielded any matching * Check if `searchQuery` presence has yielded any matching
...@@ -136,6 +144,7 @@ export default { ...@@ -136,6 +144,7 @@ export default {
'setIssueId', 'setIssueId',
'setSearchQuery', 'setSearchQuery',
'setSelectedEpic', 'setSelectedEpic',
'setSelectedEpicIssueId',
'fetchEpics', 'fetchEpics',
'assignIssueToEpic', 'assignIssueToEpic',
'removeIssueFromEpic', 'removeIssueFromEpic',
...@@ -159,7 +168,7 @@ export default { ...@@ -159,7 +168,7 @@ export default {
this.showDropdown = this.isDropdownVariantStandalone; this.showDropdown = this.isDropdownVariantStandalone;
}, },
handleItemSelect(epic) { handleItemSelect(epic) {
if (this.epicIssueId && epic.id === noneEpic.id && epic.title === noneEpic.title) { if (this.selectedEpicIssueId && epic.id === noneEpic.id && epic.title === noneEpic.title) {
this.removeIssueFromEpic(this.selectedEpic); this.removeIssueFromEpic(this.selectedEpic);
} else if (this.issueId) { } else if (this.issueId) {
this.assignIssueToEpic(epic); this.assignIssueToEpic(epic);
......
import Api from 'ee/api'; import Api from 'ee/api';
import { noneEpic } from 'ee/vue_shared/constants'; import { noneEpic } from 'ee/vue_shared/constants';
import flash from '~/flash'; import flash from '~/flash';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { formatDate, timeFor } from '~/lib/utils/datetime_utility';
import * as types from './mutation_types'; import * as types from './mutation_types';
import boardsStore from '~/boards/stores/boards_store';
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const setIssueId = ({ commit }, issueId) => commit(types.SET_ISSUE_ID, issueId); export const setIssueId = ({ commit }, issueId) => commit(types.SET_ISSUE_ID, issueId);
...@@ -16,6 +19,9 @@ export const setSearchQuery = ({ commit }, searchQuery) => ...@@ -16,6 +19,9 @@ export const setSearchQuery = ({ commit }, searchQuery) =>
export const setSelectedEpic = ({ commit }, selectedEpic) => export const setSelectedEpic = ({ commit }, selectedEpic) =>
commit(types.SET_SELECTED_EPIC, selectedEpic); commit(types.SET_SELECTED_EPIC, selectedEpic);
export const setSelectedEpicIssueId = ({ commit }, selectedEpicIssueId) =>
commit(types.SET_SELECTED_EPIC_ISSUE_ID, selectedEpicIssueId);
export const requestEpics = ({ commit }) => commit(types.REQUEST_EPICS); export const requestEpics = ({ commit }) => commit(types.REQUEST_EPICS);
export const receiveEpicsSuccess = ({ commit }, data) => { export const receiveEpicsSuccess = ({ commit }, data) => {
const epics = data.map(rawEpic => const epics = data.map(rawEpic =>
...@@ -52,14 +58,50 @@ export const fetchEpics = ({ state, dispatch }, search = '') => { ...@@ -52,14 +58,50 @@ export const fetchEpics = ({ state, dispatch }, search = '') => {
export const requestIssueUpdate = ({ commit }) => commit(types.REQUEST_ISSUE_UPDATE); export const requestIssueUpdate = ({ commit }) => commit(types.REQUEST_ISSUE_UPDATE);
export const receiveIssueUpdateSuccess = ({ state, commit }, { data, epic, isRemoval = false }) => { export const receiveIssueUpdateSuccess = ({ state, commit }, { data, epic, isRemoval = false }) => {
/*
If EpicsSelect is loaded within Boards, -
we need to update "boardsStore.issue.detail.epic" which has -
a differently formatted timestamp that includes '<strong>' tag.
However, "data.epic" in the response of the API POST doesn't have '<strong>' tag.
("epic" param is also in a different format).
*/
function insertStrongTag(humanReadableTimestamp) {
if (humanReadableTimestamp === __('Past due')) {
return `<strong>${humanReadableTimestamp}</strong>`;
}
// Insert strong tag for for any number in the string.
// I.e., "3 days remaining" or "Осталось 3 дней"
// A similar transformation is done in the backend:
// app/serializers/entity_date_helper.rb
return humanReadableTimestamp.replace(/\d+/, '<strong>$&</strong>');
}
// Verify if update was successful // Verify if update was successful
if (data.epic.id === epic.id && data.issue.id === state.issueId) { if (data.epic.id === epic.id && data.issue.id === state.issueId) {
if (boardsStore.detail.issue.updateEpic) {
const formattedEpic = isRemoval
? { epic_issue_id: noneEpic.id }
: {
epic_issue_id: data.id,
group_id: data.epic.group_id,
human_readable_end_date: formatDate(data.epic.end_date, 'mmm d, yyyy'),
human_readable_timestamp: insertStrongTag(timeFor(data.epic.end_date)),
id: data.epic.id,
iid: data.epic.iid,
title: data.epic.title,
url: `/groups/${data.epic.web_url.replace(/.+groups\//, '')}`,
};
boardsStore.detail.issue.updateEpic(formattedEpic);
}
commit(types.RECEIVE_ISSUE_UPDATE_SUCCESS, { commit(types.RECEIVE_ISSUE_UPDATE_SUCCESS, {
selectedEpic: isRemoval ? noneEpic : epic, selectedEpic: isRemoval ? noneEpic : epic,
selectedEpicIssueId: data.id, selectedEpicIssueId: data.id,
}); });
} }
}; };
/** /**
* Shows provided errorMessage in flash banner and * Shows provided errorMessage in flash banner and
* fires `RECEIVE_ISSUE_UPDATE_FAILURE` mutation * fires `RECEIVE_ISSUE_UPDATE_FAILURE` mutation
......
...@@ -4,6 +4,7 @@ export const SET_ISSUE_ID = 'SET_ISSUE_ID'; ...@@ -4,6 +4,7 @@ export const SET_ISSUE_ID = 'SET_ISSUE_ID';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const SET_SELECTED_EPIC = 'SET_SELECTED_EPIC'; export const SET_SELECTED_EPIC = 'SET_SELECTED_EPIC';
export const SET_SELECTED_EPIC_ISSUE_ID = 'SET_SELECTED_EPIC_ISSUE_ID';
export const REQUEST_EPICS = 'REQUEST_EPICS'; export const REQUEST_EPICS = 'REQUEST_EPICS';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS'; export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
......
...@@ -24,6 +24,10 @@ export default { ...@@ -24,6 +24,10 @@ export default {
state.selectedEpic = selectedEpic; state.selectedEpic = selectedEpic;
}, },
[types.SET_SELECTED_EPIC_ISSUE_ID](state, selectedEpicIssueId) {
state.selectedEpicIssueId = selectedEpicIssueId;
},
[types.REQUEST_EPICS](state) { [types.REQUEST_EPICS](state) {
state.epicsFetchInProgress = true; state.epicsFetchInProgress = true;
}, },
......
---
title: Fix issues with editing epic on Boards sidebar
merge_request: 32503
author: Eulyeon Ko
type: fixed
...@@ -151,7 +151,7 @@ describe 'Issue Boards', :js do ...@@ -151,7 +151,7 @@ describe 'Issue Boards', :js do
context 'epic' do context 'epic' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
group.add_owner(user)
visit project_board_path(project, board) visit project_board_path(project, board)
wait_for_requests wait_for_requests
end end
...@@ -166,14 +166,39 @@ describe 'Issue Boards', :js do ...@@ -166,14 +166,39 @@ describe 'Issue Boards', :js do
end end
context 'when the issue is associated with an epic' do context 'when the issue is associated with an epic' do
let(:epic) { create(:epic, group: group) } let(:epic1) { create(:epic, group: group, title: 'Foo') }
let!(:epic_issue) { create(:epic_issue, issue: issue1, epic: epic) } let!(:epic2) { create(:epic, group: group, title: 'Bar') }
let!(:epic_issue) { create(:epic_issue, issue: issue1, epic: epic1) }
it 'displays name of epic and links to it' do it 'displays name of epic and links to it' do
click_card(card1) click_card(card1)
wait_for_requests wait_for_requests
expect(find('.js-epic-label')).to have_link(epic.title, href: epic_path(epic)) expect(find('.js-epic-label')).to have_link(epic1.title, href: epic_path(epic1))
end
it 'updates the epic associated with the issue' do
click_card(card1)
wait_for_requests
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
wait_for_requests
click_link epic2.title
wait_for_requests
expect(page.find('.value')).to have_content(epic2.title)
end
# Ensure that boards_store is also updated the epic associated with the issue.
click_card(card1)
wait_for_requests
click_card(card1)
wait_for_requests
expect(find('.js-epic-label')).to have_content(epic2.title)
end end
end end
end end
......
...@@ -5,6 +5,7 @@ import testAction from 'helpers/vuex_action_helper'; ...@@ -5,6 +5,7 @@ import testAction from 'helpers/vuex_action_helper';
import createDefaultState from 'ee/vue_shared/components/sidebar/epics_select/store/state'; import createDefaultState from 'ee/vue_shared/components/sidebar/epics_select/store/state';
import * as actions from 'ee/vue_shared/components/sidebar/epics_select/store/actions'; import * as actions from 'ee/vue_shared/components/sidebar/epics_select/store/actions';
import * as types from 'ee/vue_shared/components/sidebar/epics_select/store/mutation_types'; import * as types from 'ee/vue_shared/components/sidebar/epics_select/store/mutation_types';
import boardsStore from '~/boards/stores/boards_store';
import { noneEpic } from 'ee/vue_shared/constants'; import { noneEpic } from 'ee/vue_shared/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
...@@ -88,6 +89,19 @@ describe('EpicsSelect', () => { ...@@ -88,6 +89,19 @@ describe('EpicsSelect', () => {
}); });
}); });
describe('setSelectedEpicIssueId', () => {
it('should set `selectedEpicIssueId` param on state', done => {
testAction(
actions.setSelectedEpicIssueId,
mockIssue.epic_issue_id,
state,
[{ type: types.SET_SELECTED_EPIC_ISSUE_ID, payload: mockIssue.epic_issue_id }],
[],
done,
);
});
});
describe('requestEpics', () => { describe('requestEpics', () => {
it('should set `state.epicsFetchInProgress` to true', done => { it('should set `state.epicsFetchInProgress` to true', done => {
testAction(actions.requestEpics, {}, state, [{ type: types.REQUEST_EPICS }], [], done); testAction(actions.requestEpics, {}, state, [{ type: types.REQUEST_EPICS }], [], done);
...@@ -248,6 +262,35 @@ describe('EpicsSelect', () => { ...@@ -248,6 +262,35 @@ describe('EpicsSelect', () => {
); );
}); });
it('should update the epic associated with the issue in BoardsStore if the update happened in Boards', done => {
boardsStore.detail.issue.updateEpic = jest.fn(() => {});
state.issueId = mockIssue.id;
const mockApiData = { ...mockAssignRemoveRes };
mockApiData.epic.web_url = '';
testAction(
actions.receiveIssueUpdateSuccess,
{
data: mockApiData,
epic: normalizedEpics[0],
},
state,
[
{
type: types.RECEIVE_ISSUE_UPDATE_SUCCESS,
payload: {
selectedEpic: normalizedEpics[0],
selectedEpicIssueId: mockApiData.id,
},
},
],
[],
done,
);
expect(boardsStore.detail.issue.updateEpic).toHaveBeenCalled();
});
it('should set updated selectedEpic with noneEpic to state when payload has matching Epic and Issue IDs and isRemoval param is true', done => { it('should set updated selectedEpic with noneEpic to state when payload has matching Epic and Issue IDs and isRemoval param is true', done => {
state.issueId = mockIssue.id; state.issueId = mockIssue.id;
......
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