Commit 54b0cbd2 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Kushal Pandya

Make BoardSidebarEpicSelect work in group boards

- Remove debounceAnimationFrame from board_sidebar_epic_select.vue
(Move mutations inside setActiveIssueEpic action)
- Add cache for fetched epic data in vuex store.
- Add logic for fetching epic if it doesn't exist in cache.
- Remove settingEpic state from BoardSidebarEpicSelect
(It's redundant and we should use epicFetchInProgress from vuex)
- Change the event name onEpicSelect to epic select.
parent e7e638b5
...@@ -17,8 +17,13 @@ export default { ...@@ -17,8 +17,13 @@ export default {
return state.issues[state.activeId] || {}; return state.issues[state.activeId] || {};
}, },
groupPathForActiveIssue: (_, getters) => {
const { referencePath = '' } = getters.activeIssue;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPathForActiveIssue: (_, getters) => { projectPathForActiveIssue: (_, getters) => {
const referencePath = getters.activeIssue.referencePath || ''; const { referencePath = '' } = getters.activeIssue;
return referencePath.slice(0, referencePath.indexOf('#')); return referencePath.slice(0, referencePath.indexOf('#'));
}, },
......
<script> <script>
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue'; import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { fullEpicId } from '../../boards_util';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { UPDATE_ISSUE_BY_ID } from '~/boards/stores/mutation_types'; import createFlash from '~/flash';
import { RECEIVE_FIRST_EPICS_SUCCESS } from '../../stores/mutation_types'; import { __, s__ } from '~/locale';
export default { export default {
components: { components: {
BoardEditableItem, BoardEditableItem,
EpicsSelect, EpicsSelect,
}, },
inject: ['groupId'], i18n: {
data() { epic: __('Epic'),
return { updateEpicError: s__(
loading: false, 'IssueBoards|An error occurred while assigning the selected epic to the issue.',
}; ),
fetchEpicError: s__(
'IssueBoards|An error occurred while fetching the assigned epic of the selected issue.',
),
}, },
inject: ['groupId'],
computed: { computed: {
...mapState(['epics']), ...mapState(['epics', 'epicsCacheById', 'epicFetchInProgress']),
...mapGetters(['activeIssue', 'getEpicById', 'projectPathForActiveIssue']), ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
storedEpic() { epic() {
const storedEpic = this.getEpicById(this.activeIssue.epic?.id); return this.activeIssue.epic;
const epicId = getIdFromGraphQLId(storedEpic?.id); },
epicData() {
const hasEpic = this.epic !== null;
const epicFetched = !this.epicFetchInProgress;
return { return hasEpic && epicFetched ? this.epicsCacheById[this.epic.id] : {};
...storedEpic, },
id: Number(epicId), initialEpic() {
}; return this.epic
? {
...this.epicData,
id: getIdFromGraphQLId(this.epic.id),
}
: {};
},
},
watch: {
epic: {
deep: true,
immediate: true,
async handler() {
if (this.epic) {
try {
await this.fetchEpicForActiveIssue();
} catch (e) {
createFlash({
message: this.$options.i18n.fetchEpicError,
error: e,
captureError: true,
});
}
}
},
}, },
}, },
methods: { methods: {
...mapMutations({ ...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']),
updateIssueById: UPDATE_ISSUE_BY_ID,
receiveEpicsSuccess: RECEIVE_FIRST_EPICS_SUCCESS,
}),
...mapActions(['setActiveIssueEpic']),
openEpicsDropdown() { openEpicsDropdown() {
this.$refs.epicSelect.handleEditClick(); if (!this.loading) {
this.$refs.epicSelect.handleEditClick();
}
}, },
async setEpic(selectedEpic) { async setEpic(selectedEpic) {
this.loading = true;
this.$refs.sidebarItem.collapse(); this.$refs.sidebarItem.collapse();
const epicId = selectedEpic?.id ? `gid://gitlab/Epic/${selectedEpic.id}` : null; const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null;
const input = {
epicId,
projectPath: this.projectPathForActiveIssue,
};
try { try {
const epic = await this.setActiveIssueEpic(input); await this.setActiveIssueEpic(epicId);
if (epic && !this.getEpicById(epic.id)) {
this.receiveEpicsSuccess({ epics: [epic, ...this.epics] });
}
debounceByAnimationFrame(() => {
this.updateIssueById({ issueId: this.activeIssue.id, prop: 'epic', value: epic });
this.loading = false;
})();
} catch (e) { } catch (e) {
this.loading = false; createFlash({ message: this.$options.i18n.updateEpicError, error: e, captureError: true });
} }
}, },
}, },
...@@ -72,25 +87,26 @@ export default { ...@@ -72,25 +87,26 @@ export default {
<template> <template>
<board-editable-item <board-editable-item
ref="sidebarItem" ref="sidebarItem"
:title="__('Epic')" :title="$options.i18n.epic"
:loading="loading" :loading="epicFetchInProgress"
@open="openEpicsDropdown" @open="openEpicsDropdown"
> >
<template v-if="storedEpic.title" #collapsed> <template v-if="epicData.title" #collapsed>
<a class="gl-text-gray-900! gl-font-weight-bold" href="#"> <a class="gl-text-gray-900! gl-font-weight-bold" href="#">
{{ storedEpic.title }} {{ epicData.title }}
</a> </a>
</template> </template>
<epics-select <epics-select
v-if="!epicFetchInProgress"
ref="epicSelect" ref="epicSelect"
class="gl-w-full" class="gl-w-full"
:group-id="groupId" :group-id="groupId"
:can-edit="true" :can-edit="true"
:initial-epic="storedEpic" :initial-epic="initialEpic"
:initial-epic-loading="false" :initial-epic-loading="false"
variant="standalone" variant="standalone"
:show-header="false" :show-header="false"
@onEpicSelect="setEpic" @epicSelect="setEpic"
/> />
</board-editable-item> </board-editable-item>
</template> </template>
#import "~/graphql_shared/fragments/epic.fragment.graphql"
query Epic($fullPath: ID!, $iid: ID!) {
group(fullPath: $fullPath) {
epic(iid: $iid) {
...EpicNode
}
}
}
...@@ -20,6 +20,7 @@ fragment IssueNode on Issue { ...@@ -20,6 +20,7 @@ fragment IssueNode on Issue {
relativePosition relativePosition
epic { epic {
id id
iid
} }
milestone { milestone {
id id
......
...@@ -24,6 +24,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -24,6 +24,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
import epicQuery from '../graphql/epic.query.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql'; import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql';
import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql'; import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql';
...@@ -150,6 +151,7 @@ export default { ...@@ -150,6 +151,7 @@ export default {
if (!withLists) { if (!withLists) {
commit(types.RECEIVE_EPICS_SUCCESS, epicsFormatted); commit(types.RECEIVE_EPICS_SUCCESS, epicsFormatted);
commit(types.UPDATE_CACHED_EPICS, epicsFormatted);
} else { } else {
if (lists) { if (lists) {
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists)); commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
...@@ -160,6 +162,7 @@ export default { ...@@ -160,6 +162,7 @@ export default {
epics: epicsFormatted, epics: epicsFormatted,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic, canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
}); });
commit(types.UPDATE_CACHED_EPICS, epicsFormatted);
} }
} }
...@@ -332,14 +335,52 @@ export default { ...@@ -332,14 +335,52 @@ export default {
commit(types.RESET_EPICS); commit(types.RESET_EPICS);
}, },
setActiveIssueEpic: async ({ getters }, input) => { fetchEpicForActiveIssue: async ({ state, commit, getters }) => {
if (!getters.activeIssue.epic) {
return false;
}
const {
epic: { id, iid },
} = getters.activeIssue;
if (state.epicsCacheById[id]) {
return false;
}
commit(types.SET_EPIC_FETCH_IN_PROGRESS, true);
try {
const {
data: {
group: { epic },
},
} = await gqlClient.query({
query: epicQuery,
variables: {
fullPath: getters.groupPathForActiveIssue,
iid,
},
});
commit(types.UPDATE_CACHED_EPICS, [epic]);
} finally {
commit(types.SET_EPIC_FETCH_IN_PROGRESS, false);
}
return true;
},
setActiveIssueEpic: async ({ state, commit, getters }, epicId) => {
commit(types.SET_EPIC_FETCH_IN_PROGRESS, true);
const { data } = await gqlClient.mutate({ const { data } = await gqlClient.mutate({
mutation: issueSetEpicMutation, mutation: issueSetEpicMutation,
variables: { variables: {
input: { input: {
iid: String(getters.activeIssue.iid), iid: String(getters.activeIssue.iid),
epicId: input.epicId, epicId,
projectPath: input.projectPath, projectPath: getters.projectPathForActiveIssue,
}, },
}, },
}); });
...@@ -348,7 +389,19 @@ export default { ...@@ -348,7 +389,19 @@ export default {
throw new Error(data.issueSetEpic.errors); throw new Error(data.issueSetEpic.errors);
} }
return data.issueSetEpic.issue.epic; const { epic } = data.issueSetEpic.issue;
if (epic !== null) {
commit(types.RECEIVE_FIRST_EPICS_SUCCESS, { epics: [epic, ...state.epics] });
commit(types.UPDATE_CACHED_EPICS, [epic]);
}
commit(typesCE.UPDATE_ISSUE_BY_ID, {
issueId: getters.activeIssue.id,
prop: 'epic',
value: epic ? { id: epic.id, iid: epic.iid } : null,
});
commit(types.SET_EPIC_FETCH_IN_PROGRESS, false);
}, },
setActiveIssueWeight: async ({ commit, getters }, input) => { setActiveIssueWeight: async ({ commit, getters }, input) => {
......
...@@ -16,10 +16,6 @@ export default { ...@@ -16,10 +16,6 @@ export default {
return getters.getIssuesByList(listId).filter((i) => Boolean(i.epic) === false); return getters.getIssuesByList(listId).filter((i) => Boolean(i.epic) === false);
}, },
getEpicById: (state) => (epicId) => {
return state.epics.find((epic) => epic.id === epicId);
},
shouldUseGraphQL: (state) => { shouldUseGraphQL: (state) => {
return state.isShowingEpicsSwimlanes || gon?.features?.graphqlBoardLists; return state.isShowingEpicsSwimlanes || gon?.features?.graphqlBoardLists;
}, },
......
...@@ -22,6 +22,8 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; ...@@ -22,6 +22,8 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE'; export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_FIRST_EPICS_SUCCESS = 'RECEIVE_FIRST_EPICS_SUCCESS'; export const RECEIVE_FIRST_EPICS_SUCCESS = 'RECEIVE_FIRST_EPICS_SUCCESS';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS'; export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const UPDATE_CACHED_EPICS = 'UPDATE_CACHED_EPICS';
export const SET_EPIC_FETCH_IN_PROGRESS = 'SET_EPIC_FETCH_IN_PROGRESS';
export const RESET_EPICS = 'RESET_EPICS'; export const RESET_EPICS = 'RESET_EPICS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS'; export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS'; export const SET_FILTERS = 'SET_FILTERS';
......
...@@ -123,7 +123,7 @@ export default { ...@@ -123,7 +123,7 @@ export default {
}, },
[mutationTypes.RECEIVE_FIRST_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => { [mutationTypes.RECEIVE_FIRST_EPICS_SUCCESS]: (state, { epics, canAdminEpic }) => {
Vue.set(state, 'epics', epics); Vue.set(state, 'epics', unionBy(state.epics || [], epics, 'id'));
if (canAdminEpic !== undefined) { if (canAdminEpic !== undefined) {
state.canAdminEpic = canAdminEpic; state.canAdminEpic = canAdminEpic;
} }
...@@ -133,6 +133,16 @@ export default { ...@@ -133,6 +133,16 @@ export default {
Vue.set(state, 'epics', unionBy(state.epics || [], epics, 'id')); Vue.set(state, 'epics', unionBy(state.epics || [], epics, 'id'));
}, },
[mutationTypes.UPDATE_CACHED_EPICS]: (state, epics) => {
epics.forEach((e) => {
Vue.set(state.epicsCacheById, e.id, e);
});
},
[mutationTypes.SET_EPIC_FETCH_IN_PROGRESS]: (state, val) => {
state.epicFetchInProgress = val;
},
[mutationTypes.RESET_EPICS]: (state) => { [mutationTypes.RESET_EPICS]: (state) => {
Vue.set(state, 'epics', []); Vue.set(state, 'epics', []);
}, },
......
...@@ -6,6 +6,10 @@ export default () => ({ ...@@ -6,6 +6,10 @@ export default () => ({
canAdminEpic: false, canAdminEpic: false,
isShowingEpicsSwimlanes: false, isShowingEpicsSwimlanes: false,
epicsSwimlanesFetchInProgress: false, epicsSwimlanesFetchInProgress: false,
// The epic data stored in 'epics' do not always persist
// and will be cleared with changes to the filter.
epics: [], epics: [],
epicsCacheById: {},
epicFetchInProgress: false,
epicsFlags: {}, epicsFlags: {},
}); });
...@@ -180,7 +180,7 @@ export default { ...@@ -180,7 +180,7 @@ export default {
} else if (this.issueId) { } else if (this.issueId) {
this.assignIssueToEpic(epic); this.assignIssueToEpic(epic);
} else { } else {
this.$emit('onEpicSelect', epic); this.$emit('epicSelect', epic);
} }
}, },
}, },
......
...@@ -41,7 +41,7 @@ export default () => { ...@@ -41,7 +41,7 @@ export default () => {
showHeader: Boolean(el.dataset.showHeader), showHeader: Boolean(el.dataset.showHeader),
}, },
on: { on: {
onEpicSelect: this.handleEpicSelect.bind(this), epicSelect: this.handleEpicSelect.bind(this),
}, },
}); });
}, },
......
import { nextTick } from 'vue'; import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue'; import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import BoardSidebarEpicSelect from 'ee/boards/components/sidebar/board_sidebar_epic_select.vue'; import BoardSidebarEpicSelect from 'ee/boards/components/sidebar/board_sidebar_epic_select.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { createStore } from '~/boards/stores'; import getters from '~/boards/stores/getters';
import {
mockIssue3 as mockIssueWithoutEpic,
mockIssueWithEpic,
mockAssignedEpic,
} from '../../mock_data';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
const TEST_GROUP_ID = 7; jest.mock('~/flash');
const TEST_EPIC_ID = 8;
const TEST_EPIC = { id: 'gid://gitlab/Epic/1', title: 'Test epic' };
const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, epic: null, referencePath: 'h/b#2' };
jest.mock('~/lib/utils/common_utils', () => ({ debounceByAnimationFrame: (callback) => callback })); const mockGroupId = 7;
describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
let wrapper; let wrapper;
...@@ -23,16 +27,32 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -23,16 +27,32 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
wrapper = null; wrapper = null;
}); });
const fakeStore = ({
initialState = {
activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
epicsCacheById: {},
epicFetchInProgress: false,
},
actionsMock = {},
} = {}) => {
store = new Vuex.Store({
state: initialState,
getters,
actions: {
...actionsMock,
},
});
};
let epicsSelectHandleEditClick; let epicsSelectHandleEditClick;
const createWrapper = () => { const createWrapper = () => {
epicsSelectHandleEditClick = jest.fn(); epicsSelectHandleEditClick = jest.fn();
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
wrapper = shallowMount(BoardSidebarEpicSelect, { wrapper = shallowMount(BoardSidebarEpicSelect, {
store, store,
provide: { provide: {
groupId: TEST_GROUP_ID, groupId: mockGroupId,
canUpdate: true, canUpdate: true,
}, },
stubs: { stubs: {
...@@ -44,58 +64,154 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -44,58 +64,154 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
}), }),
}, },
}); });
store.state.epics = [TEST_EPIC];
store.state.issues = { [TEST_ISSUE.id]: TEST_ISSUE };
store.state.activeId = TEST_ISSUE.id;
}; };
const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' }); const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' });
const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' }); const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' });
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders "None" when no epic is selected', () => { it('renders "None" when no epic is assigned to the active issue', async () => {
fakeStore();
createWrapper(); createWrapper();
await wrapper.vm.$nextTick();
expect(findCollapsed().text()).toBe('None'); expect(findCollapsed().text()).toBe('None');
}); });
describe('when active issue has an assigned epic', () => {
it('fetches an epic for active issue', () => {
const fetchEpicForActiveIssue = jest.fn(() => Promise.resolve());
fakeStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: {},
epicFetchInProgress: true,
},
actionsMock: {
fetchEpicForActiveIssue,
},
});
createWrapper();
expect(fetchEpicForActiveIssue).toHaveBeenCalledTimes(1);
});
it('flashes an error message when fetch fails', async () => {
fakeStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: {},
epicFetchInProgress: true,
},
actionsMock: {
fetchEpicForActiveIssue: jest.fn().mockRejectedValue('mayday'),
},
});
createWrapper();
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.fetchEpicError,
error: 'mayday',
captureError: true,
});
});
it('renders epic title when issue has an assigned epic', async () => {
fakeStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: { [mockAssignedEpic.id]: { ...mockAssignedEpic } },
epicFetchInProgress: false,
},
});
createWrapper();
await wrapper.vm.$nextTick();
expect(findCollapsed().text()).toBe(mockAssignedEpic.title);
});
});
it('expands the dropdown when editing', () => { it('expands the dropdown when editing', () => {
fakeStore();
createWrapper(); createWrapper();
findItemWrapper().vm.$emit('open'); findItemWrapper().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled(); expect(epicsSelectHandleEditClick).toHaveBeenCalled();
}); });
describe('when epic is selected', () => { describe('when epic is selected', () => {
beforeEach(() => { beforeEach(async () => {
fakeStore({
initialState: {
activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
epicsCacheById: {},
epicFetchInProgress: false,
},
});
createWrapper(); createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => TEST_EPIC);
findEpicSelect().vm.$emit('onEpicSelect', { ...TEST_EPIC, id: TEST_EPIC_ID });
return nextTick();
});
it('collapses sidebar and renders epic title', () => { jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
expect(findCollapsed().isVisible()).toBe(true); // 'setActiveIssueEpic' sets the active issue's epic to the selected epic
expect(findCollapsed().text()).toBe(TEST_EPIC.title); // and stores the assigned epic's data in 'epicsCacheById'
store.state.epicFetchInProgress = true;
store.state.issues[mockIssueWithoutEpic.id].epic = { ...mockAssignedEpic };
store.state.epicsCacheById = { [mockAssignedEpic.id]: { ...mockAssignedEpic } };
store.state.epicFetchInProgress = false;
});
findEpicSelect().vm.$emit('epicSelect', {
...mockAssignedEpic,
id: getIdFromGraphQLId(mockAssignedEpic.id),
});
await wrapper.vm.$nextTick();
}); });
it('commits change to the server', () => { it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith({ expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(mockAssignedEpic.id);
epicId: `gid://gitlab/Epic/${TEST_EPIC_ID}`, expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
projectPath: 'h/b',
});
}); });
it('updates issue with the selected epic', () => { it('collapses sidebar and renders epic title', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toEqual(TEST_EPIC); expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(mockAssignedEpic.title);
}); });
}); });
describe('when no epic is selected', () => { describe('when no epic is selected', () => {
beforeEach(() => { beforeEach(async () => {
fakeStore({
initialState: {
activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: { [mockAssignedEpic.id]: { ...mockAssignedEpic } },
epicFetchInProgress: false,
},
});
createWrapper(); createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => null);
findEpicSelect().vm.$emit('onEpicSelect', null); jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
return nextTick(); // Remove assigned epic from the active issue
store.state.issues[mockIssueWithoutEpic.id].epic = null;
});
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
}); });
it('collapses sidebar and renders "None"', () => { it('collapses sidebar and renders "None"', () => {
...@@ -103,31 +219,30 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -103,31 +219,30 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
expect(findCollapsed().text()).toBe('None'); expect(findCollapsed().text()).toBe('None');
}); });
it('updates issue with a null epic', () => { it('commits change to the server', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toBe(null); expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(null);
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
}); });
}); });
describe('when the mutation fails', () => { it('flashes an error when update fails', async () => {
const issueWithEpic = { ...TEST_ISSUE, epic: TEST_EPIC }; fakeStore({
actionsMock: {
beforeEach(() => { setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'),
createWrapper(); },
store.state.issues = { [TEST_ISSUE.id]: { ...issueWithEpic } };
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findEpicSelect().vm.$emit('onEpicSelect', {});
return nextTick();
}); });
it('collapses sidebar and renders former issue epic', () => { createWrapper();
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(TEST_EPIC.title); findEpicSelect().vm.$emit('epicSelect', null);
});
await wrapper.vm.$nextTick();
it('does not commit changes to the store', () => { expect(createFlash).toHaveBeenCalledTimes(1);
expect(store.state.issues[issueWithEpic.id]).toEqual(issueWithEpic); expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.updateEpicError,
error: 'mayday',
captureError: true,
}); });
}); });
}); });
...@@ -106,6 +106,7 @@ export const mockIssue = { ...@@ -106,6 +106,7 @@ export const mockIssue = {
labels, labels,
epic: { epic: {
id: 'gid://gitlab/Epic/41', id: 'gid://gitlab/Epic/41',
iid: 2,
}, },
}; };
...@@ -123,6 +124,7 @@ export const mockIssue2 = { ...@@ -123,6 +124,7 @@ export const mockIssue2 = {
labels, labels,
epic: { epic: {
id: 'gid://gitlab/Epic/40', id: 'gid://gitlab/Epic/40',
iid: 1,
}, },
}; };
...@@ -171,6 +173,9 @@ export const mockEpic = { ...@@ -171,6 +173,9 @@ export const mockEpic = {
issues: [mockIssue], issues: [mockIssue],
}; };
export const mockIssueWithEpic = { ...mockIssue3, epic: { id: mockEpic.id, iid: mockEpic.iid } };
export const mockAssignedEpic = { ...mockIssueWithEpic.epic, title: mockEpic.title };
export const mockEpics = [ export const mockEpics = [
{ {
id: 'gid://gitlab/Epic/41', id: 'gid://gitlab/Epic/41',
......
...@@ -156,7 +156,7 @@ describe('fetchEpicsSwimlanes', () => { ...@@ -156,7 +156,7 @@ describe('fetchEpicsSwimlanes', () => {
}, },
}; };
it('should commit mutation RECEIVE_EPICS_SUCCESS on success without lists', (done) => { it('should commit mutation RECEIVE_EPICS_SUCCESS and UPDATE_CACHED_EPICS on success without lists', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction( testAction(
...@@ -168,6 +168,10 @@ describe('fetchEpicsSwimlanes', () => { ...@@ -168,6 +168,10 @@ describe('fetchEpicsSwimlanes', () => {
type: types.RECEIVE_EPICS_SUCCESS, type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic], payload: [mockEpic],
}, },
{
type: types.UPDATE_CACHED_EPICS,
payload: [mockEpic],
},
], ],
[], [],
done, done,
...@@ -214,6 +218,10 @@ describe('fetchEpicsSwimlanes', () => { ...@@ -214,6 +218,10 @@ describe('fetchEpicsSwimlanes', () => {
type: types.RECEIVE_EPICS_SUCCESS, type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic], payload: [mockEpic],
}, },
{
type: types.UPDATE_CACHED_EPICS,
payload: [mockEpic],
},
], ],
[ [
{ {
...@@ -531,26 +539,174 @@ describe('resetEpics', () => { ...@@ -531,26 +539,174 @@ describe('resetEpics', () => {
}); });
}); });
describe('fetchEpicForActiveIssue', () => {
const assignedEpic = {
id: mockIssue.epic.id,
iid: mockIssue.epic.iid,
};
describe("when active issue doesn't have an assigned epic", () => {
const getters = { activeIssue: { ...mockIssue, epic: null } };
it('should not fetch any epic', async () => {
await testAction(actions.fetchEpicForActiveIssue, undefined, { ...getters }, [], []);
});
});
describe('when the assigned epic for active issue is found in state.epicsCacheById', () => {
const getters = { activeIssue: { ...mockIssue, epic: assignedEpic } };
const state = { epicsCacheById: { [assignedEpic.id]: assignedEpic } };
it('should not fetch any epic', async () => {
await testAction(
actions.fetchEpicForActiveIssue,
undefined,
{ ...state, ...getters },
[],
[],
);
});
});
describe('when fetching fails', () => {
const getters = { activeIssue: { ...mockIssue, epic: assignedEpic } };
const state = { epicsCacheById: {} };
it('should not commit UPDATE_CACHED_EPICS mutation and should throw an error', () => {
const mockError = new Error('mayday');
jest.spyOn(gqlClient, 'query').mockRejectedValue(mockError);
return testAction(
actions.fetchEpicForActiveIssue,
undefined,
{ ...state, ...getters },
[
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: true,
},
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: false,
},
],
[],
).catch((e) => {
expect(e).toEqual(mockError);
});
});
});
describe("when the assigned epic for active issue isn't found in state.epicsCacheById", () => {
const getters = { activeIssue: { ...mockIssue, epic: assignedEpic } };
const state = { epicsCacheById: {} };
it('should commit mutation SET_EPIC_FETCH_IN_PROGRESS before and after committing mutation UPDATE_CACHED_EPICS', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue({ data: { group: { epic: mockEpic } } });
await testAction(
actions.fetchEpicForActiveIssue,
undefined,
{ ...state, ...getters },
[
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: true,
},
{
type: types.UPDATE_CACHED_EPICS,
payload: [mockEpic],
},
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: false,
},
],
[],
);
});
});
});
describe('setActiveIssueEpic', () => { describe('setActiveIssueEpic', () => {
const getters = { activeIssue: mockIssue }; const state = {
epics: [{ id: 'gid://gitlab/Epic/422', iid: 99, title: 'existing epic' }],
};
const getters = { activeIssue: { ...mockIssue, projectPath: 'h/b' } };
const epicWithData = { const epicWithData = {
id: 'gid://gitlab/Epic/42', id: 'gid://gitlab/Epic/42',
iid: 1, iid: 1,
title: 'Epic title', title: 'Epic title',
}; };
const input = {
epicId: epicWithData.id,
projectPath: 'h/b',
};
it('should return epic after setting the issue', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetEpic: { issue: { epic: epicWithData } } } });
const result = await actions.setActiveIssueEpic({ getters }, input); describe('when the updated issue has an assigned epic', () => {
it('should commit mutation RECEIVE_FIRST_EPICS_SUCCESS, UPDATE_CACHED_EPICS and UPDATE_ISSUE_BY_ID on success', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetEpic: { issue: { epic: epicWithData } } } });
await testAction(
actions.setActiveIssueEpic,
epicWithData.id,
{ ...state, ...getters },
[
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: true,
},
{
type: types.RECEIVE_FIRST_EPICS_SUCCESS,
payload: { epics: [epicWithData, ...state.epics] },
},
{
type: types.UPDATE_CACHED_EPICS,
payload: [epicWithData],
},
{
type: typesCE.UPDATE_ISSUE_BY_ID,
payload: {
issueId: mockIssue.id,
prop: 'epic',
value: { id: epicWithData.id, iid: epicWithData.iid },
},
},
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: false,
},
],
[],
);
});
});
expect(result.id).toEqual(epicWithData.id); describe('when the updated issue does not have an epic (unassigned)', () => {
it('should only commit UPDATE_ISSUE_BY_ID on success', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetEpic: { issue: { epic: null } } } });
await testAction(
actions.setActiveIssueEpic,
null,
{ ...state, ...getters },
[
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: true,
},
{
type: typesCE.UPDATE_ISSUE_BY_ID,
payload: { issueId: mockIssue.id, prop: 'epic', value: null },
},
{
type: types.SET_EPIC_FETCH_IN_PROGRESS,
payload: false,
},
],
[],
);
});
}); });
it('throws error if fails', async () => { it('throws error if fails', async () => {
...@@ -558,7 +714,7 @@ describe('setActiveIssueEpic', () => { ...@@ -558,7 +714,7 @@ describe('setActiveIssueEpic', () => {
.spyOn(gqlClient, 'mutate') .spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetEpic: { errors: ['failed mutation'] } } }); .mockResolvedValue({ data: { issueSetEpic: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error); await expect(actions.setActiveIssueEpic({ getters }, epicWithData.id)).rejects.toThrow(Error);
}); });
}); });
......
...@@ -90,10 +90,4 @@ describe('EE Boards Store Getters', () => { ...@@ -90,10 +90,4 @@ describe('EE Boards Store Getters', () => {
).toEqual([mockIssue3, mockIssue4]); ).toEqual([mockIssue3, mockIssue4]);
}); });
}); });
describe('getEpicById', () => {
it('returns epic for a given id', () => {
expect(getters.getEpicById(boardsState)(mockEpics[0].id)).toEqual(mockEpics[0]);
});
});
}); });
...@@ -218,6 +218,18 @@ describe('RECEIVE_FIRST_EPICS_SUCCESS', () => { ...@@ -218,6 +218,18 @@ describe('RECEIVE_FIRST_EPICS_SUCCESS', () => {
expect(state.epics).toEqual(mockEpics); expect(state.epics).toEqual(mockEpics);
expect(state.canAdminEpic).toEqual(true); expect(state.canAdminEpic).toEqual(true);
}); });
it('merges epics while avoiding duplicates', () => {
state = {
...state,
epics: mockEpics,
canAdminEpic: false,
};
mutations.RECEIVE_FIRST_EPICS_SUCCESS(state, mockEpics);
expect(state.epics).toEqual(mockEpics);
});
}); });
describe('RECEIVE_EPICS_SUCCESS', () => { describe('RECEIVE_EPICS_SUCCESS', () => {
......
...@@ -176,7 +176,7 @@ describe('EpicsSelect', () => { ...@@ -176,7 +176,7 @@ describe('EpicsSelect', () => {
expect(wrapper.vm.assignIssueToEpic).toHaveBeenCalledWith(mockEpic2); expect(wrapper.vm.assignIssueToEpic).toHaveBeenCalledWith(mockEpic2);
}); });
it('should emit component event `onEpicSelect` with both `epicIssueId` & `issueId` props are not defined', () => { it('should emit component event `epicSelect` with both `epicIssueId` & `issueId` props are not defined', () => {
wrapperStandalone.setProps({ wrapperStandalone.setProps({
issueId: 0, issueId: 0,
epicIssueId: 0, epicIssueId: 0,
...@@ -185,8 +185,8 @@ describe('EpicsSelect', () => { ...@@ -185,8 +185,8 @@ describe('EpicsSelect', () => {
return wrapperStandalone.vm.$nextTick(() => { return wrapperStandalone.vm.$nextTick(() => {
wrapperStandalone.vm.handleItemSelect(mockEpic2); wrapperStandalone.vm.handleItemSelect(mockEpic2);
expect(wrapperStandalone.emitted('onEpicSelect')).toBeTruthy(); expect(wrapperStandalone.emitted('epicSelect')).toBeTruthy();
expect(wrapperStandalone.emitted('onEpicSelect')[0]).toEqual([mockEpic2]); expect(wrapperStandalone.emitted('epicSelect')[0]).toEqual([mockEpic2]);
}); });
}); });
}); });
......
...@@ -15896,6 +15896,12 @@ msgstr "" ...@@ -15896,6 +15896,12 @@ msgstr ""
msgid "IssueAnalytics|Weight" msgid "IssueAnalytics|Weight"
msgstr "" msgstr ""
msgid "IssueBoards|An error occurred while assigning the selected epic to the issue."
msgstr ""
msgid "IssueBoards|An error occurred while fetching the assigned epic of the selected issue."
msgstr ""
msgid "IssueBoards|An error occurred while setting notifications status. Please try again." msgid "IssueBoards|An error occurred while setting notifications status. Please try again."
msgstr "" msgstr ""
......
...@@ -62,6 +62,22 @@ describe('Boards - Getters', () => { ...@@ -62,6 +62,22 @@ describe('Boards - Getters', () => {
}); });
}); });
describe('groupPathByIssueId', () => {
it('returns group path for the active issue', () => {
const mockActiveIssue = {
referencePath: 'gitlab-org/gitlab-test#1',
};
expect(getters.groupPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(
'gitlab-org',
);
});
it('returns empty string as group path when active issue is an empty object', () => {
const mockActiveIssue = {};
expect(getters.groupPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual('');
});
});
describe('projectPathByIssueId', () => { describe('projectPathByIssueId', () => {
it('returns project path for the active issue', () => { it('returns project path for the active issue', () => {
const mockActiveIssue = { const mockActiveIssue = {
...@@ -72,7 +88,7 @@ describe('Boards - Getters', () => { ...@@ -72,7 +88,7 @@ describe('Boards - Getters', () => {
); );
}); });
it('returns empty string as project when active issue is an empty object', () => { it('returns empty string as project path when active issue is an empty object', () => {
const mockActiveIssue = {}; const mockActiveIssue = {};
expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(''); expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual('');
}); });
......
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