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 {
return state.issues[state.activeId] || {};
},
groupPathForActiveIssue: (_, getters) => {
const { referencePath = '' } = getters.activeIssue;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPathForActiveIssue: (_, getters) => {
const referencePath = getters.activeIssue.referencePath || '';
const { referencePath = '' } = getters.activeIssue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
......
<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 { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { fullEpicId } from '../../boards_util';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { UPDATE_ISSUE_BY_ID } from '~/boards/stores/mutation_types';
import { RECEIVE_FIRST_EPICS_SUCCESS } from '../../stores/mutation_types';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
export default {
components: {
BoardEditableItem,
EpicsSelect,
},
inject: ['groupId'],
data() {
return {
loading: false,
};
i18n: {
epic: __('Epic'),
updateEpicError: s__(
'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: {
...mapState(['epics']),
...mapGetters(['activeIssue', 'getEpicById', 'projectPathForActiveIssue']),
storedEpic() {
const storedEpic = this.getEpicById(this.activeIssue.epic?.id);
const epicId = getIdFromGraphQLId(storedEpic?.id);
...mapState(['epics', 'epicsCacheById', 'epicFetchInProgress']),
...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
epic() {
return this.activeIssue.epic;
},
epicData() {
const hasEpic = this.epic !== null;
const epicFetched = !this.epicFetchInProgress;
return {
...storedEpic,
id: Number(epicId),
};
return hasEpic && epicFetched ? this.epicsCacheById[this.epic.id] : {};
},
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: {
...mapMutations({
updateIssueById: UPDATE_ISSUE_BY_ID,
receiveEpicsSuccess: RECEIVE_FIRST_EPICS_SUCCESS,
}),
...mapActions(['setActiveIssueEpic']),
...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']),
openEpicsDropdown() {
this.$refs.epicSelect.handleEditClick();
if (!this.loading) {
this.$refs.epicSelect.handleEditClick();
}
},
async setEpic(selectedEpic) {
this.loading = true;
this.$refs.sidebarItem.collapse();
const epicId = selectedEpic?.id ? `gid://gitlab/Epic/${selectedEpic.id}` : null;
const input = {
epicId,
projectPath: this.projectPathForActiveIssue,
};
const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null;
try {
const epic = await this.setActiveIssueEpic(input);
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;
})();
await this.setActiveIssueEpic(epicId);
} catch (e) {
this.loading = false;
createFlash({ message: this.$options.i18n.updateEpicError, error: e, captureError: true });
}
},
},
......@@ -72,25 +87,26 @@ export default {
<template>
<board-editable-item
ref="sidebarItem"
:title="__('Epic')"
:loading="loading"
:title="$options.i18n.epic"
:loading="epicFetchInProgress"
@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="#">
{{ storedEpic.title }}
{{ epicData.title }}
</a>
</template>
<epics-select
v-if="!epicFetchInProgress"
ref="epicSelect"
class="gl-w-full"
:group-id="groupId"
:can-edit="true"
:initial-epic="storedEpic"
:initial-epic="initialEpic"
:initial-epic-loading="false"
variant="standalone"
:show-header="false"
@onEpicSelect="setEpic"
@epicSelect="setEpic"
/>
</board-editable-item>
</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 {
relativePosition
epic {
id
iid
}
milestone {
id
......
......@@ -24,6 +24,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/boards/eventhub';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import epicQuery from '../graphql/epic.query.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql';
import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql';
......@@ -150,6 +151,7 @@ export default {
if (!withLists) {
commit(types.RECEIVE_EPICS_SUCCESS, epicsFormatted);
commit(types.UPDATE_CACHED_EPICS, epicsFormatted);
} else {
if (lists) {
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
......@@ -160,6 +162,7 @@ export default {
epics: epicsFormatted,
canAdminEpic: epics.edges[0]?.node?.userPermissions?.adminEpic,
});
commit(types.UPDATE_CACHED_EPICS, epicsFormatted);
}
}
......@@ -332,14 +335,52 @@ export default {
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({
mutation: issueSetEpicMutation,
variables: {
input: {
iid: String(getters.activeIssue.iid),
epicId: input.epicId,
projectPath: input.projectPath,
epicId,
projectPath: getters.projectPathForActiveIssue,
},
},
});
......@@ -348,7 +389,19 @@ export default {
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) => {
......
......@@ -16,10 +16,6 @@ export default {
return getters.getIssuesByList(listId).filter((i) => Boolean(i.epic) === false);
},
getEpicById: (state) => (epicId) => {
return state.epics.find((epic) => epic.id === epicId);
},
shouldUseGraphQL: (state) => {
return state.isShowingEpicsSwimlanes || gon?.features?.graphqlBoardLists;
},
......
......@@ -22,6 +22,8 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_FIRST_EPICS_SUCCESS = 'RECEIVE_FIRST_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 SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS';
......
......@@ -123,7 +123,7 @@ export default {
},
[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) {
state.canAdminEpic = canAdminEpic;
}
......@@ -133,6 +133,16 @@ export default {
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) => {
Vue.set(state, 'epics', []);
},
......
......@@ -6,6 +6,10 @@ export default () => ({
canAdminEpic: false,
isShowingEpicsSwimlanes: false,
epicsSwimlanesFetchInProgress: false,
// The epic data stored in 'epics' do not always persist
// and will be cleared with changes to the filter.
epics: [],
epicsCacheById: {},
epicFetchInProgress: false,
epicsFlags: {},
});
......@@ -180,7 +180,7 @@ export default {
} else if (this.issueId) {
this.assignIssueToEpic(epic);
} else {
this.$emit('onEpicSelect', epic);
this.$emit('epicSelect', epic);
}
},
},
......
......@@ -41,7 +41,7 @@ export default () => {
showHeader: Boolean(el.dataset.showHeader),
},
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 EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import BoardSidebarEpicSelect from 'ee/boards/components/sidebar/board_sidebar_epic_select.vue';
import { stubComponent } from 'helpers/stub_component';
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;
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('~/flash');
jest.mock('~/lib/utils/common_utils', () => ({ debounceByAnimationFrame: (callback) => callback }));
const mockGroupId = 7;
describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
let wrapper;
......@@ -23,16 +27,32 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
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;
const createWrapper = () => {
epicsSelectHandleEditClick = jest.fn();
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
wrapper = shallowMount(BoardSidebarEpicSelect, {
store,
provide: {
groupId: TEST_GROUP_ID,
groupId: mockGroupId,
canUpdate: true,
},
stubs: {
......@@ -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 findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' });
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();
await wrapper.vm.$nextTick();
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', () => {
fakeStore();
createWrapper();
findItemWrapper().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
describe('when epic is selected', () => {
beforeEach(() => {
beforeEach(async () => {
fakeStore({
initialState: {
activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
epicsCacheById: {},
epicFetchInProgress: false,
},
});
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', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(TEST_EPIC.title);
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
// 'setActiveIssueEpic' sets the active issue's epic to the selected epic
// 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', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith({
epicId: `gid://gitlab/Epic/${TEST_EPIC_ID}`,
projectPath: 'h/b',
});
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(mockAssignedEpic.id);
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
});
it('updates issue with the selected epic', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toEqual(TEST_EPIC);
it('collapses sidebar and renders epic title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(mockAssignedEpic.title);
});
});
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();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => null);
findEpicSelect().vm.$emit('onEpicSelect', null);
return nextTick();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
// 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"', () => {
......@@ -103,31 +219,30 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
expect(findCollapsed().text()).toBe('None');
});
it('updates issue with a null epic', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toBe(null);
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(null);
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
});
});
describe('when the mutation fails', () => {
const issueWithEpic = { ...TEST_ISSUE, epic: TEST_EPIC };
beforeEach(() => {
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('flashes an error when update fails', async () => {
fakeStore({
actionsMock: {
setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'),
},
});
it('collapses sidebar and renders former issue epic', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(TEST_EPIC.title);
});
createWrapper();
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
it('does not commit changes to the store', () => {
expect(store.state.issues[issueWithEpic.id]).toEqual(issueWithEpic);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.updateEpicError,
error: 'mayday',
captureError: true,
});
});
});
......@@ -106,6 +106,7 @@ export const mockIssue = {
labels,
epic: {
id: 'gid://gitlab/Epic/41',
iid: 2,
},
};
......@@ -123,6 +124,7 @@ export const mockIssue2 = {
labels,
epic: {
id: 'gid://gitlab/Epic/40',
iid: 1,
},
};
......@@ -171,6 +173,9 @@ export const mockEpic = {
issues: [mockIssue],
};
export const mockIssueWithEpic = { ...mockIssue3, epic: { id: mockEpic.id, iid: mockEpic.iid } };
export const mockAssignedEpic = { ...mockIssueWithEpic.epic, title: mockEpic.title };
export const mockEpics = [
{
id: 'gid://gitlab/Epic/41',
......
......@@ -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);
testAction(
......@@ -168,6 +168,10 @@ describe('fetchEpicsSwimlanes', () => {
type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic],
},
{
type: types.UPDATE_CACHED_EPICS,
payload: [mockEpic],
},
],
[],
done,
......@@ -214,6 +218,10 @@ describe('fetchEpicsSwimlanes', () => {
type: types.RECEIVE_EPICS_SUCCESS,
payload: [mockEpic],
},
{
type: types.UPDATE_CACHED_EPICS,
payload: [mockEpic],
},
],
[
{
......@@ -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', () => {
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 = {
id: 'gid://gitlab/Epic/42',
iid: 1,
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 () => {
......@@ -558,7 +714,7 @@ describe('setActiveIssueEpic', () => {
.spyOn(gqlClient, 'mutate')
.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', () => {
).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', () => {
expect(state.epics).toEqual(mockEpics);
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', () => {
......
......@@ -176,7 +176,7 @@ describe('EpicsSelect', () => {
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({
issueId: 0,
epicIssueId: 0,
......@@ -185,8 +185,8 @@ describe('EpicsSelect', () => {
return wrapperStandalone.vm.$nextTick(() => {
wrapperStandalone.vm.handleItemSelect(mockEpic2);
expect(wrapperStandalone.emitted('onEpicSelect')).toBeTruthy();
expect(wrapperStandalone.emitted('onEpicSelect')[0]).toEqual([mockEpic2]);
expect(wrapperStandalone.emitted('epicSelect')).toBeTruthy();
expect(wrapperStandalone.emitted('epicSelect')[0]).toEqual([mockEpic2]);
});
});
});
......
......@@ -15896,6 +15896,12 @@ msgstr ""
msgid "IssueAnalytics|Weight"
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."
msgstr ""
......
......@@ -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', () => {
it('returns project path for the active issue', () => {
const mockActiveIssue = {
......@@ -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 = {};
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