Commit 41b03ea7 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '230985-allow-users-to-create-a-new-label-list-within-an-epic-board' into 'master'

Add label list to Epic board [RUN-AS-IF-FOSS]

See merge request gitlab-org/gitlab!55011
parents 3aa2b656 efe1beb2
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['labels', 'labelsLoading']), ...mapState(['labels', 'labelsLoading', 'isEpicBoard']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL']), ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
selectedLabel() { selectedLabel() {
return this.labels.find(({ id }) => id === this.selectedLabelId); return this.labels.find(({ id }) => id === this.selectedLabelId);
...@@ -57,7 +57,7 @@ export default { ...@@ -57,7 +57,7 @@ export default {
methods: { methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
getListByLabel(label) { getListByLabel(label) {
if (this.shouldUseGraphQL) { if (this.shouldUseGraphQL || this.isEpicBoard) {
return this.getListByLabelId(label); return this.getListByLabelId(label);
} }
return boardsStore.findListByLabelId(label.id); return boardsStore.findListByLabelId(label.id);
...@@ -66,7 +66,7 @@ export default { ...@@ -66,7 +66,7 @@ export default {
return Boolean(this.getListByLabel(label)); return Boolean(this.getListByLabel(label));
}, },
highlight(listId) { highlight(listId) {
if (this.shouldUseGraphQL) { if (this.shouldUseGraphQL || this.isEpicBoard) {
this.highlightList(listId); this.highlightList(listId);
} else { } else {
const list = boardsStore.state.lists.find(({ id }) => id === listId); const list = boardsStore.state.lists.find(({ id }) => id === listId);
...@@ -95,7 +95,7 @@ export default { ...@@ -95,7 +95,7 @@ export default {
return; return;
} }
if (this.shouldUseGraphQL) { if (this.shouldUseGraphQL || this.isEpicBoard) {
this.createList({ labelId: this.selectedLabelId }); this.createList({ labelId: this.selectedLabelId });
} else { } else {
boardsStore.new({ boardsStore.new({
...@@ -127,6 +127,7 @@ export default { ...@@ -127,6 +127,7 @@ export default {
<template> <template>
<div <div
class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0" class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
data-testid="board-add-new-column"
data-qa-selector="board_add_new_list" data-qa-selector="board_add_new_list"
> >
<div <div
......
...@@ -128,7 +128,11 @@ export default { ...@@ -128,7 +128,11 @@ export default {
}, flashAnimationDuration); }, flashAnimationDuration);
}, },
createList: ( createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
},
createIssueList: (
{ state, commit, dispatch, getters }, { state, commit, dispatch, getters },
{ backlog, labelId, milestoneId, assigneeId }, { backlog, labelId, milestoneId, assigneeId },
) => { ) => {
...@@ -172,7 +176,7 @@ export default { ...@@ -172,7 +176,7 @@ export default {
}, },
fetchLabels: ({ state, commit, getters }, searchTerm) => { fetchLabels: ({ state, commit, getters }, searchTerm) => {
const { fullPath, boardType } = state; const { fullPath, boardType, isEpicBoard } = state;
const variables = { const variables = {
fullPath, fullPath,
...@@ -191,7 +195,7 @@ export default { ...@@ -191,7 +195,7 @@ export default {
.then(({ data }) => { .then(({ data }) => {
let labels = data[boardType]?.labels.nodes; let labels = data[boardType]?.labels.nodes;
if (!getters.shouldUseGraphQL) { if (!getters.shouldUseGraphQL && !isEpicBoard) {
labels = labels.map((label) => ({ labels = labels.map((label) => ({
...label, ...label,
id: getIdFromGraphQLId(label.id), id: getIdFromGraphQLId(label.id),
......
...@@ -30,6 +30,7 @@ ...@@ -30,6 +30,7 @@
color: var(--gray-500, $gray-500); color: var(--gray-500, $gray-500);
} }
[data-page$='epic_boards:show'],
.issue-boards-page { .issue-boards-page {
.content-wrapper { .content-wrapper {
padding-bottom: 0; padding-bottom: 0;
......
...@@ -197,7 +197,7 @@ ...@@ -197,7 +197,7 @@
#js-board-epics-swimlanes-toggle #js-board-epics-swimlanes-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } } .js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } }
- if user_can_admin_list - if user_can_admin_list
- if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) || board.to_type == "EpicBoard"
.js-create-column-trigger{ data: board_list_data } .js-create-column-trigger{ data: board_list_data }
- else - else
= render 'shared/issuable/board_create_list_dropdown', board: board = render 'shared/issuable/board_create_list_dropdown', board: board
......
#import "~/graphql_shared/fragments/label.fragment.graphql"
fragment EpicBoardListFragment on EpicList {
id
title
position
listType
label {
...Label
}
}
#import "./epic_board_list.fragment.graphql"
mutation CreateEpicBoardList($boardId: BoardsEpicBoardID!, $backlog: Boolean, $labelId: LabelID) {
epicBoardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
list {
...EpicBoardListFragment
}
errors
}
}
#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./epic_board_list.fragment.graphql"
query ListEpics($fullPath: ID!, $boardId: BoardsEpicBoardID!) { query ListEpics($fullPath: ID!, $boardId: BoardsEpicBoardID!) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
epicBoard(id: $boardId) { epicBoard(id: $boardId) {
lists { lists {
nodes { nodes {
id ...EpicBoardListFragment
title
position
listType
label {
...Label
}
} }
} }
} }
......
...@@ -30,6 +30,7 @@ import { ...@@ -30,6 +30,7 @@ import {
import { EpicFilterType, IterationFilterType, GroupByParamType } from '../constants'; import { EpicFilterType, IterationFilterType, GroupByParamType } from '../constants';
import epicQuery from '../graphql/epic.query.graphql'; import epicQuery from '../graphql/epic.query.graphql';
import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql';
import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql'; import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
...@@ -554,4 +555,48 @@ export default { ...@@ -554,4 +555,48 @@ export default {
}) })
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
}, },
createList: ({ state, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
const { isEpicBoard } = state;
if (!isEpicBoard) {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
} else {
dispatch('createEpicList', { backlog, labelId });
}
},
createEpicList: ({ state, commit, dispatch, getters }, { backlog, labelId }) => {
const { boardId } = state;
const existingList = getters.getListByLabelId(labelId);
if (existingList) {
dispatch('highlightList', existingList.id);
return;
}
gqlClient
.mutate({
mutation: createEpicBoardListMutation,
variables: {
boardId: fullEpicBoardId(boardId),
backlog,
labelId,
},
})
.then(({ data }) => {
if (data?.epicBoardListCreate?.errors.length) {
commit(types.CREATE_LIST_FAILURE);
} else {
const list = data.epicBoardListCreate?.list;
dispatch('addList', list);
dispatch('highlightList', list.id);
}
})
.catch((e) => {
commit(types.CREATE_LIST_FAILURE);
throw e;
});
},
}; };
...@@ -31,4 +31,5 @@ export const SET_FILTERS = 'SET_FILTERS'; ...@@ -31,4 +31,5 @@ export const SET_FILTERS = 'SET_FILTERS';
export const MOVE_ISSUE = 'MOVE_ISSUE'; export const MOVE_ISSUE = 'MOVE_ISSUE';
export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES'; export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES';
...@@ -9,6 +9,7 @@ import { mapActions } from 'vuex'; ...@@ -9,6 +9,7 @@ import { mapActions } from 'vuex';
import BoardSidebar from 'ee_component/boards/components/board_sidebar'; import BoardSidebar from 'ee_component/boards/components/board_sidebar';
import toggleLabels from 'ee_component/boards/toggle_labels'; import toggleLabels from 'ee_component/boards/toggle_labels';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardContent from '~/boards/components/board_content.vue'; import BoardContent from '~/boards/components/board_content.vue';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import mountMultipleBoardsSwitcher from '~/boards/mount_multiple_boards_switcher'; import mountMultipleBoardsSwitcher from '~/boards/mount_multiple_boards_switcher';
...@@ -110,6 +111,21 @@ export default () => { ...@@ -110,6 +111,21 @@ export default () => {
}, },
}); });
const createColumnTriggerEl = document.querySelector('.js-create-column-trigger');
if (createColumnTriggerEl) {
// eslint-disable-next-line no-new
new Vue({
el: createColumnTriggerEl,
components: {
BoardAddNewColumnTrigger,
},
store,
render(createElement) {
return createElement(BoardAddNewColumnTrigger);
},
});
}
toggleLabels(); toggleLabels();
mountMultipleBoardsSwitcher({ mountMultipleBoardsSwitcher({
......
...@@ -8,16 +8,19 @@ RSpec.describe 'epic boards', :js do ...@@ -8,16 +8,19 @@ RSpec.describe 'epic boards', :js do
let_it_be(:epic_board) { create(:epic_board, group: group) } let_it_be(:epic_board) { create(:epic_board, group: group) }
let_it_be(:label) { create(:group_label, group: group, name: 'Label1') } let_it_be(:label) { create(:group_label, group: group, name: 'Label1') }
let_it_be(:label2) { create(:group_label, group: group, name: 'Label2') }
let_it_be(:label_list) { create(:epic_list, epic_board: epic_board, label: label, position: 0) } let_it_be(:label_list) { create(:epic_list, epic_board: epic_board, label: label, position: 0) }
let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) } let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) }
let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) } let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) }
let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') } let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') }
let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') } let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') }
let_it_be(:epic3) { create(:epic, group: group, labels: [label2], title: 'Epic3') }
context 'display epics in board' do context 'display epics in board' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
group.add_maintainer(user)
sign_in(user) sign_in(user)
visit_epic_boards_page visit_epic_boards_page
end end
...@@ -34,10 +37,14 @@ RSpec.describe 'epic boards', :js do ...@@ -34,10 +37,14 @@ RSpec.describe 'epic boards', :js do
end end
end end
it 'displays one epic in Open list' do it 'displays two epics in Open list' do
page.within("[data-board-type='backlog']") do page.within("[data-board-type='backlog']") do
expect(page).to have_selector('.board-card', count: 1) expect(page).to have_selector('.board-card', count: 2)
page.within(first('.board-card')) do page.within(first('.board-card')) do
expect(page).to have_content('Epic3')
end
page.within('.board-card:nth-child(2)') do
expect(page).to have_content('Epic2') expect(page).to have_content('Epic2')
end end
end end
...@@ -51,6 +58,21 @@ RSpec.describe 'epic boards', :js do ...@@ -51,6 +58,21 @@ RSpec.describe 'epic boards', :js do
end end
end end
end end
it 'creates new column for label containing labeled issue' do
click_button 'Create list'
wait_for_all_requests
page.within("[data-testid='board-add-new-column']") do
find('label', text: label2.title).click
click_button 'Add'
end
wait_for_all_requests
expect(page).to have_selector('.board', text: label2.title)
expect(find('.board:nth-child(3) .board-card')).to have_content(epic3.title)
end
end end
def visit_epic_boards_page def visit_epic_boards_page
......
...@@ -947,4 +947,112 @@ describe('moveIssue', () => { ...@@ -947,4 +947,112 @@ describe('moveIssue', () => {
done, done,
); );
}); });
describe.each`
isEpicBoard | dispatchedAction
${false} | ${'createIssueList'}
${true} | ${'createEpicList'}
`('createList', ({ isEpicBoard, dispatchedAction }) => {
it(`should dispatch ${dispatchedAction} action when isEpicBoard is ${isEpicBoard} on state`, async () => {
await testAction({
action: actions.createList,
payload: { backlog: true },
state: { isEpicBoard },
expectedActions: [{ type: dispatchedAction, payload: { backlog: true } }],
});
});
});
describe('createEpicList', () => {
let commit;
let dispatch;
let getters;
beforeEach(() => {
commit = jest.fn();
dispatch = jest.fn();
getters = {
getListByLabelId: jest.fn(),
};
});
it('should dispatch addList action when creating backlog list', async () => {
const backlogList = {
id: 'gid://gitlab/List/1',
listType: 'backlog',
title: 'Open',
position: 0,
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: backlogList,
errors: [],
},
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
});
it('dispatches highlightList after addList has succeeded', async () => {
const list = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Open',
labelId: '4',
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list,
errors: [],
},
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
});
it('should commit CREATE_LIST_FAILURE mutation when API returns an error', async () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
epicBoardListCreate: {
list: {},
errors: [{ foo: 'bar' }],
},
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
});
it('highlights list and does not re-query if it already exists', async () => {
const existingList = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Some label',
position: 1,
};
getters = {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(commit).not.toHaveBeenCalled();
});
});
}); });
...@@ -210,6 +210,16 @@ describe('fetchIssueLists', () => { ...@@ -210,6 +210,16 @@ describe('fetchIssueLists', () => {
}); });
describe('createList', () => { describe('createList', () => {
it('should dispatch createIssueList action', () => {
testAction({
action: actions.createList,
payload: { backlog: true },
expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }],
});
});
});
describe('createIssueList', () => {
let commit; let commit;
let dispatch; let dispatch;
let getters; let getters;
...@@ -249,7 +259,7 @@ describe('createList', () => { ...@@ -249,7 +259,7 @@ describe('createList', () => {
}), }),
); );
await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList); expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
}); });
...@@ -271,7 +281,7 @@ describe('createList', () => { ...@@ -271,7 +281,7 @@ describe('createList', () => {
}, },
}); });
await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' }); await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list); expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id); expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
...@@ -289,7 +299,7 @@ describe('createList', () => { ...@@ -289,7 +299,7 @@ describe('createList', () => {
}), }),
); );
await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE); expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
}); });
...@@ -306,7 +316,7 @@ describe('createList', () => { ...@@ -306,7 +316,7 @@ describe('createList', () => {
getListByLabelId: jest.fn().mockReturnValue(existingList), getListByLabelId: jest.fn().mockReturnValue(existingList),
}; };
await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id); expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1);
......
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