Commit 9afe5498 authored by Axel García's avatar Axel García

Add epic selector to swimlanes sidebar

It uses the epic-select component to fetch epics
and handle the selection logic.
parent 0961dce3
...@@ -5,6 +5,7 @@ import { ISSUABLE } from '~/boards/constants'; ...@@ -5,6 +5,7 @@ import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils'; import { contentTop } from '~/lib/utils/common_utils';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import IssuableTitle from '~/boards/components/issuable_title.vue'; import IssuableTitle from '~/boards/components/issuable_title.vue';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
export default { export default {
headerHeight: `${contentTop()}px`, headerHeight: `${contentTop()}px`,
...@@ -12,6 +13,7 @@ export default { ...@@ -12,6 +13,7 @@ export default {
IssuableAssignees, IssuableAssignees,
GlDrawer, GlDrawer,
IssuableTitle, IssuableTitle,
BoardSidebarEpicSelect,
}, },
computed: { computed: {
...mapGetters(['isSidebarOpen', 'getActiveIssue']), ...mapGetters(['isSidebarOpen', 'getActiveIssue']),
...@@ -39,6 +41,7 @@ export default { ...@@ -39,6 +41,7 @@ export default {
<template> <template>
<issuable-assignees :users="getActiveIssue.assignees" /> <issuable-assignees :users="getActiveIssue.assignees" />
<board-sidebar-epic-select />
</template> </template>
</gl-drawer> </gl-drawer>
</template> </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 issueSetEpic from '../../queries/issue_set_epic.mutation.graphql';
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(['fetchIssuesForEpic']),
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 { data } = await this.$apollo.mutate({
mutation: issueSetEpic,
variables: {
input: {
epicId,
iid: String(this.issue.iid),
projectPath: this.projectPath,
},
},
});
if (data.issueSetEpic.errors?.length > 0) {
this.loading = false;
return;
}
const { epic } = data.issueSetEpic.issue;
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;
},
},
};
</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
}
}
...@@ -10,4 +10,8 @@ export default { ...@@ -10,4 +10,8 @@ export default {
getUnassignedIssues: (state, getters) => listId => { getUnassignedIssues: (state, getters) => listId => {
return getters.getIssues(listId).filter(i => Boolean(i.epic) === false); 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 { ...@@ -39,11 +39,13 @@ export default {
}, },
issueId: { issueId: {
type: Number, type: Number,
required: true, required: false,
default: 0,
}, },
epicIssueId: { epicIssueId: {
type: Number, type: Number,
required: true, required: false,
default: 0,
}, },
canEdit: { canEdit: {
type: Boolean, type: Boolean,
......
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
<template> <template>
<button <button
type="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-display="static"
data-toggle="dropdown" data-toggle="dropdown"
> >
......
...@@ -39,7 +39,7 @@ export default { ...@@ -39,7 +39,7 @@ export default {
type="search" type="search"
@keyup="handleKeyUp" @keyup="handleKeyUp"
/> />
<gl-icon v-show="!query" name="search" /> <gl-icon v-show="!query" class="dropdown-input-search" name="search" />
<gl-button <gl-button
variant="link" variant="link"
icon="close" icon="close"
......
...@@ -17,6 +17,9 @@ describe('ee/BoardContentSidebar', () => { ...@@ -17,6 +17,9 @@ describe('ee/BoardContentSidebar', () => {
rootPath: '', rootPath: '',
}, },
store, 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 issueSetEpic from 'ee/boards/queries/issue_set_epic.mutation.graphql';
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' };
const TEST_EPIC_SET = { data: { issueSetEpic: { issue: { ...TEST_ISSUE, epic: TEST_EPIC } } } };
const TEST_FAILED_EPIC_SET = { data: { issueSetEpic: { errors: ['mutation failed'] } } };
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 = ({ mutationResult = TEST_EPIC_SET } = {}) => {
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
wrapper = shallowMount(BoardSidebarEpicSelect, {
store,
provide: {
groupId: TEST_GROUP_ID,
canUpdate: true,
},
stubs: {
'board-editable-item': BoardEditableItem,
},
mocks: {
$apollo: {
mutate: jest.fn().mockResolvedValue(mutationResult),
},
},
});
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();
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.$apollo.mutate).toHaveBeenCalledWith({
mutation: issueSetEpic,
variables: {
input: {
epicId: `gid://gitlab/Epic/${TEST_EPIC_ID}`,
iid: String(TEST_ISSUE.iid),
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', () => {
const issueWithoutEpic = { data: { issueSetEpic: { issue: { ...TEST_ISSUE } } } };
beforeEach(async () => {
createWrapper({ mutationResult: issueWithoutEpic });
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 };
const faultyEpic = { id: '?', title: 'Faulty epic' };
beforeEach(async () => {
createWrapper({ mutationResult: TEST_FAILED_EPIC_SET });
store.state.issues = { [TEST_ISSUE.id]: { ...issueWithEpic } };
findEpicSelect().vm.$emit('onEpicSelect', faultyEpic);
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);
});
});
});
...@@ -5,12 +5,14 @@ import { ...@@ -5,12 +5,14 @@ import {
mockIssue4, mockIssue4,
mockIssues, mockIssues,
mockIssuesByListId, mockIssuesByListId,
mockEpics,
issues, issues,
} from '../mock_data'; } from '../mock_data';
describe('EE Boards Store Getters', () => { describe('EE Boards Store Getters', () => {
const boardsState = { const boardsState = {
issuesByListId: mockIssuesByListId, issuesByListId: mockIssuesByListId,
epics: mockEpics,
issues, issues,
}; };
...@@ -34,4 +36,10 @@ describe('EE Boards Store Getters', () => { ...@@ -34,4 +36,10 @@ 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]);
});
});
}); });
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