Commit fdaa208c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '37100-sidebar-epic-should-be-a-presentational-component-in-vue' into 'master'

Add epic component to swimlanes board sidebar

See merge request gitlab-org/gitlab!38752
parents 39bc9676 03947a82
......@@ -5,6 +5,7 @@ import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
export default {
headerHeight: `${contentTop()}px`,
......@@ -12,6 +13,7 @@ export default {
IssuableAssignees,
GlDrawer,
IssuableTitle,
BoardSidebarEpicSelect,
},
computed: {
...mapGetters(['isSidebarOpen', 'getActiveIssue']),
......@@ -39,6 +41,7 @@ export default {
<template>
<issuable-assignees :users="getActiveIssue.assignees" />
<board-sidebar-epic-select />
</template>
</gl-drawer>
</template>
<script>
import { mapState, mapGetters, mapMutations, 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 BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { UPDATE_ISSUE_BY_ID } from '~/boards/stores/mutation_types';
import { RECEIVE_EPICS_SUCCESS } from '../../stores/mutation_types';
export default {
components: {
BoardEditableItem,
EpicsSelect,
},
data() {
return {
loading: false,
};
},
inject: ['groupId'],
computed: {
...mapState(['epics']),
...mapGetters({ getEpicById: 'getEpicById', issue: 'getActiveIssue' }),
storedEpic() {
const storedEpic = this.getEpicById(this.issue.epic?.id);
const epicId = getIdFromGraphQLId(storedEpic?.id);
return {
...storedEpic,
id: Number(epicId),
};
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapMutations({
updateIssueById: UPDATE_ISSUE_BY_ID,
receiveEpicsSuccess: RECEIVE_EPICS_SUCCESS,
}),
...mapActions(['setActiveIssueEpic']),
handleEdit(isEditing) {
if (isEditing) {
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.projectPath,
};
try {
const epic = await this.setActiveIssueEpic(input);
if (epic && !this.getEpicById(epic.id)) {
this.receiveEpicsSuccess([epic, ...this.epics]);
}
debounceByAnimationFrame(() => {
this.updateIssueById({ issueId: this.issue.id, prop: 'epic', value: epic });
this.loading = false;
})();
} catch (e) {
this.loading = false;
}
},
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="__('Epic')"
:loading="loading"
@changed="handleEdit"
>
<template v-if="storedEpic.title" #collapsed>
<a class="gl-text-gray-900! gl-font-weight-bold" href="#">
{{ storedEpic.title }}
</a>
</template>
<template>
<epics-select
ref="epicSelect"
class="gl-w-full"
:group-id="groupId"
:can-edit="true"
:initial-epic="storedEpic"
:initial-epic-loading="false"
variant="standalone"
:show-header="false"
@onEpicSelect="setEpic"
/>
</template>
</board-editable-item>
</template>
#import "~/graphql_shared/fragments/epic.fragment.graphql"
mutation issueSetEpic($input: IssueSetEpicInput!) {
issueSetEpic(input: $input) {
issue {
epic {
...EpicNode
}
}
errors
}
}
......@@ -15,6 +15,7 @@ import eventHub from '~/boards/eventhub';
import createDefaultClient from '~/lib/graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
import issueSetEpic from '../queries/issue_set_epic.mutation.graphql';
import listsIssuesQuery from '~/boards/queries/lists_issues.query.graphql';
const notImplemented = () => {
......@@ -234,4 +235,23 @@ export default {
resetEpics: ({ commit }) => {
commit(types.RESET_EPICS);
},
setActiveIssueEpic: async ({ getters }, input) => {
const { data } = await gqlClient.mutate({
mutation: issueSetEpic,
variables: {
input: {
iid: String(getters.getActiveIssue.iid),
epicId: input.epicId,
projectPath: input.projectPath,
},
},
});
if (data.issueSetEpic.errors?.length > 0) {
throw new Error(data.issueSetEpic.errors);
}
return data.issueSetEpic.issue.epic;
},
};
......@@ -10,4 +10,8 @@ export default {
getUnassignedIssues: (state, getters) => listId => {
return getters.getIssues(listId).filter(i => Boolean(i.epic) === false);
},
getEpicById: state => epicId => {
return state.epics.find(epic => epic.id === epicId);
},
};
......@@ -39,11 +39,13 @@ export default {
},
issueId: {
type: Number,
required: true,
required: false,
default: 0,
},
epicIssueId: {
type: Number,
required: true,
required: false,
default: 0,
},
canEdit: {
type: Boolean,
......
......@@ -29,7 +29,7 @@ export default {
<template>
<button
type="button"
class="dropdown-menu-toggle js-epic-select js-extra-options"
class="dropdown-menu-toggle js-epic-select js-extra-options gl-w-full"
data-display="static"
data-toggle="dropdown"
>
......
......@@ -39,7 +39,7 @@ export default {
type="search"
@keyup="handleKeyUp"
/>
<gl-icon v-show="!query" name="search" />
<gl-icon v-show="!query" class="dropdown-input-search" name="search" />
<gl-button
variant="link"
icon="close"
......
......@@ -17,6 +17,9 @@ describe('ee/BoardContentSidebar', () => {
rootPath: '',
},
store,
stubs: {
'board-sidebar-epic-select': '<div></div>',
},
});
};
......
import { shallowMount } from '@vue/test-utils';
import BoardSidebarEpicSelect from 'ee/boards/components/sidebar/board_sidebar_epic_select.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { createStore } from '~/boards/stores';
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('~/lib/utils/common_utils', () => ({ debounceByAnimationFrame: callback => callback }));
describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = () => {
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
wrapper = shallowMount(BoardSidebarEpicSelect, {
store,
provide: {
groupId: TEST_GROUP_ID,
canUpdate: true,
},
stubs: {
'board-editable-item': BoardEditableItem,
},
});
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 findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders "None" when no epic is selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
describe('when epic is selected', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => TEST_EPIC);
findEpicSelect().vm.$emit('onEpicSelect', { ...TEST_EPIC, id: TEST_EPIC_ID });
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders epic title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(TEST_EPIC.title);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith({
epicId: `gid://gitlab/Epic/${TEST_EPIC_ID}`,
projectPath: 'h/b',
});
});
it('updates issue with the selected epic', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toEqual(TEST_EPIC);
});
});
describe('when no epic is selected', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => null);
findEpicSelect().vm.$emit('onEpicSelect', null);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
it('updates issue with a null epic', () => {
expect(store.state.issues[TEST_ISSUE.id].epic).toBe(null);
});
});
describe('when the mutation fails', () => {
const issueWithEpic = { ...TEST_ISSUE, epic: TEST_EPIC };
beforeEach(async () => {
createWrapper();
store.state.issues = { [TEST_ISSUE.id]: { ...issueWithEpic } };
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findEpicSelect().vm.$emit('onEpicSelect', {});
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue epic', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(TEST_EPIC.title);
});
it('does not commit changes to the store', () => {
expect(store.state.issues[issueWithEpic.id]).toEqual(issueWithEpic);
});
});
});
......@@ -350,3 +350,34 @@ describe('resetEpics', () => {
return testAction(actions.resetEpics, {}, {}, [{ type: types.RESET_EPICS }], []);
});
});
describe('setActiveIssueEpic', () => {
const getters = { getActiveIssue: mockIssue };
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);
expect(result.id).toEqual(epicWithData.id);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetEpic: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueEpic({ getters }, input)).rejects.toThrow(Error);
});
});
......@@ -5,12 +5,14 @@ import {
mockIssue4,
mockIssues,
mockIssuesByListId,
mockEpics,
issues,
} from '../mock_data';
describe('EE Boards Store Getters', () => {
const boardsState = {
issuesByListId: mockIssuesByListId,
epics: mockEpics,
issues,
};
......@@ -34,4 +36,10 @@ 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]);
});
});
});
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