Commit 39130866 authored by Simon Knox's avatar Simon Knox Committed by Ezekiel Kigbo

Add iteration lists to board new list form

Allow adding iteration lists to boards
Supports graphql and plain ol boards
parent ffcbbe06
...@@ -104,6 +104,9 @@ export default () => { ...@@ -104,6 +104,9 @@ export default () => {
? parseInt($boardApp.dataset.boardWeight, 10) ? parseInt($boardApp.dataset.boardWeight, 10)
: null, : null,
scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels), scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels),
milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
}, },
store, store,
apolloProvider, apolloProvider,
......
...@@ -134,7 +134,7 @@ export default { ...@@ -134,7 +134,7 @@ export default {
createIssueList: ( createIssueList: (
{ state, commit, dispatch, getters }, { state, commit, dispatch, getters },
{ backlog, labelId, milestoneId, assigneeId }, { backlog, labelId, milestoneId, assigneeId, iterationId },
) => { ) => {
const { boardId } = state; const { boardId } = state;
...@@ -154,6 +154,7 @@ export default { ...@@ -154,6 +154,7 @@ export default {
labelId, labelId,
milestoneId, milestoneId,
assigneeId, assigneeId,
iterationId,
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
......
...@@ -575,7 +575,7 @@ const boardsStore = { ...@@ -575,7 +575,7 @@ const boardsStore = {
}, },
saveList(list) { saveList(list) {
const entity = list.label || list.assignee || list.milestone; const entity = list.label || list.assignee || list.milestone || list.iteration;
let entityType = ''; let entityType = '';
if (list.label) { if (list.label) {
entityType = 'label_id'; entityType = 'label_id';
...@@ -583,6 +583,8 @@ const boardsStore = { ...@@ -583,6 +583,8 @@ const boardsStore = {
entityType = 'assignee_id'; entityType = 'assignee_id';
} else if (IS_EE && list.milestone) { } else if (IS_EE && list.milestone) {
entityType = 'milestone_id'; entityType = 'milestone_id';
} else if (IS_EE && list.iteration) {
entityType = 'iteration_id';
} }
return this.createList(entity.id, entityType) return this.createList(entity.id, entityType)
......
...@@ -17,4 +17,10 @@ fragment BoardListFragment on BoardList { ...@@ -17,4 +17,10 @@ fragment BoardListFragment on BoardList {
webPath webPath
description description
} }
iteration {
id
title
webPath
description
}
} }
...@@ -5,6 +5,7 @@ mutation CreateBoardList( ...@@ -5,6 +5,7 @@ mutation CreateBoardList(
$backlog: Boolean $backlog: Boolean
$labelId: LabelID $labelId: LabelID
$milestoneId: MilestoneID $milestoneId: MilestoneID
$iterationId: IterationID
$assigneeId: UserID $assigneeId: UserID
) { ) {
boardListCreate( boardListCreate(
...@@ -13,6 +14,7 @@ mutation CreateBoardList( ...@@ -13,6 +14,7 @@ mutation CreateBoardList(
backlog: $backlog backlog: $backlog
labelId: $labelId labelId: $labelId
milestoneId: $milestoneId milestoneId: $milestoneId
iterationId: $iterationId
assigneeId: $assigneeId assigneeId: $assigneeId
} }
) { ) {
......
query GroupBoardIterations($fullPath: ID!, $title: String) {
group(fullPath: $fullPath) {
iterations(includeAncestors: true, title: $title) {
nodes {
id
title
}
}
}
}
query ProjectBoardIterations($fullPath: ID!, $title: String) {
project(fullPath: $fullPath) {
iterations(includeAncestors: true, title: $title) {
nodes {
id
title
}
}
}
}
...@@ -35,6 +35,7 @@ import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql'; ...@@ -35,6 +35,7 @@ import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql';
import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql'; import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardAssigneesQuery from '../graphql/group_board_assignees.query.graphql'; import groupBoardAssigneesQuery from '../graphql/group_board_assignees.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql'; import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql';
...@@ -42,6 +43,7 @@ import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql ...@@ -42,6 +43,7 @@ import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql
import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql'; import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql';
import listsEpicsQuery from '../graphql/lists_epics.query.graphql'; import listsEpicsQuery from '../graphql/lists_epics.query.graphql';
import projectBoardAssigneesQuery from '../graphql/project_board_assignees.query.graphql'; import projectBoardAssigneesQuery from '../graphql/project_board_assignees.query.graphql';
import projectBoardIterationsQuery from '../graphql/project_board_iterations.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql'; import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql';
...@@ -648,6 +650,50 @@ export default { ...@@ -648,6 +650,50 @@ export default {
}); });
}, },
fetchIterations({ state, commit }, title) {
commit(types.RECEIVE_ITERATIONS_REQUEST);
const { fullPath, boardType } = state;
const variables = {
fullPath,
title,
};
let query;
if (boardType === BoardType.project) {
query = projectBoardIterationsQuery;
}
if (boardType === BoardType.group) {
query = groupBoardIterationsQuery;
}
if (!query) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unknown board type');
}
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const errors = data[boardType]?.errors;
const iterations = data[boardType]?.iterations.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
}
commit(types.RECEIVE_ITERATIONS_SUCCESS, iterations);
})
.catch((e) => {
commit(types.RECEIVE_ITERATIONS_FAILURE);
throw e;
});
},
fetchAssignees({ state, commit }, search) { fetchAssignees({ state, commit }, search) {
commit(types.RECEIVE_ASSIGNEES_REQUEST); commit(types.RECEIVE_ASSIGNEES_REQUEST);
...@@ -694,9 +740,12 @@ export default { ...@@ -694,9 +740,12 @@ export default {
}); });
}, },
createList: ({ getters, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { createList: (
{ getters, dispatch },
{ backlog, labelId, milestoneId, assigneeId, iterationId },
) => {
if (!getters.isEpicBoard) { if (!getters.isEpicBoard) {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId }); dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId, iterationId });
} else { } else {
dispatch('createEpicList', { backlog, labelId }); dispatch('createEpicList', { backlog, labelId });
} }
......
...@@ -38,6 +38,9 @@ export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES' ...@@ -38,6 +38,9 @@ export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES'
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST'; export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE'; export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST';
export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS';
export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE';
export const RECEIVE_ASSIGNEES_REQUEST = 'RECEIVE_ASSIGNEES_REQUEST'; export const RECEIVE_ASSIGNEES_REQUEST = 'RECEIVE_ASSIGNEES_REQUEST';
export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS'; export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
export const RECEIVE_ASSIGNEES_FAILURE = 'RECEIVE_ASSIGNEES_FAILURE'; export const RECEIVE_ASSIGNEES_FAILURE = 'RECEIVE_ASSIGNEES_FAILURE';
...@@ -233,6 +233,20 @@ export default { ...@@ -233,6 +233,20 @@ export default {
state.error = __('Failed to load milestones.'); state.error = __('Failed to load milestones.');
}, },
[mutationTypes.RECEIVE_ITERATIONS_REQUEST](state) {
state.iterationsLoading = true;
},
[mutationTypes.RECEIVE_ITERATIONS_SUCCESS](state, iterations) {
state.iterations = iterations;
state.iterationsLoading = false;
},
[mutationTypes.RECEIVE_ITERATIONS_FAILURE](state) {
state.iterationsLoading = false;
state.error = __('Failed to load iterations.');
},
[mutationTypes.RECEIVE_ASSIGNEES_REQUEST](state) { [mutationTypes.RECEIVE_ASSIGNEES_REQUEST](state) {
state.assigneesLoading = true; state.assigneesLoading = true;
}, },
......
...@@ -14,6 +14,8 @@ export default () => ({ ...@@ -14,6 +14,8 @@ export default () => ({
epicsFlags: {}, epicsFlags: {},
milestones: [], milestones: [],
milestonesLoading: false, milestonesLoading: false,
iterations: [],
iterationsLoading: false,
assignees: [], assignees: [],
assigneesLoading: false, assigneesLoading: false,
}); });
...@@ -26,6 +26,9 @@ module EE ...@@ -26,6 +26,9 @@ module EE
labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ), labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ),
board_weight: board.weight, board_weight: board.weight,
weight_feature_available: current_board_parent.feature_available?(:issue_weights).to_s, weight_feature_available: current_board_parent.feature_available?(:issue_weights).to_s,
milestone_lists_available: current_board_parent.feature_available?(:board_milestone_lists).to_s,
assignee_lists_available: current_board_parent.feature_available?(:board_assignee_lists).to_s,
iteration_lists_available: current_board_parent.feature_available?(:board_iteration_lists).to_s,
show_promotion: show_feature_promotion, show_promotion: show_feature_promotion,
scoped_labels: current_board_parent.feature_available?(:scoped_labels)&.to_s scoped_labels: current_board_parent.feature_available?(:scoped_labels)&.to_s
} }
......
...@@ -12,11 +12,13 @@ RSpec.describe 'User adds milestone lists', :js do ...@@ -12,11 +12,13 @@ RSpec.describe 'User adds milestone lists', :js do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:milestone) { create(:milestone, group: group) } let_it_be(:milestone) { create(:milestone, group: group) }
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) } let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) }
let_it_be(:issue_with_milestone) { create(:issue, project: project, milestone: milestone) } let_it_be(:issue_with_milestone) { create(:issue, project: project, milestone: milestone) }
let_it_be(:issue_with_assignee) { create(:issue, project: project, assignees: [user]) } let_it_be(:issue_with_assignee) { create(:issue, project: project, assignees: [user]) }
let_it_be(:issue_with_iteration) { create(:issue, project: project, iteration: iteration) }
before_all do before_all do
project.add_maintainer(user) project.add_maintainer(user)
...@@ -34,7 +36,8 @@ RSpec.describe 'User adds milestone lists', :js do ...@@ -34,7 +36,8 @@ RSpec.describe 'User adds milestone lists', :js do
before do before do
stub_licensed_features( stub_licensed_features(
board_milestone_lists: true, board_milestone_lists: true,
board_assignee_lists: true board_assignee_lists: true,
board_iteration_lists: true
) )
sign_in(user) sign_in(user)
...@@ -67,6 +70,45 @@ RSpec.describe 'User adds milestone lists', :js do ...@@ -67,6 +70,45 @@ RSpec.describe 'User adds milestone lists', :js do
expect(page).to have_selector('.board', text: user.name) expect(page).to have_selector('.board', text: user.name)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_assignee.title) expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_assignee.title)
end end
it 'creates iteration column' do
add_list('Iteration', iteration.title)
expect(page).to have_selector('.board', text: iteration.title)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_iteration.title)
end
end
describe 'without a license' do
before do
stub_licensed_features(
board_milestone_lists: false,
board_assignee_lists: false,
board_iteration_lists: false
)
sign_in(user)
stub_feature_flags(
board_new_list: true
)
visit project_board_path(project, project_board)
wait_for_all_requests
end
it 'does not show other list types' do
click_button 'Create list'
wait_for_all_requests
page.within(find("[data-testid='board-add-new-column']")) do
expect(page).not_to have_text('Iteration')
expect(page).not_to have_text('Assignee')
expect(page).not_to have_text('Milestone')
expect(page).not_to have_text('List type')
end
end
end end
def add_list(list_type, title) def add_list(list_type, title)
......
import { GlAvatarLabeled, GlSearchBoxByType, GlFormSelect } from '@gitlab/ui'; import { GlAvatarLabeled, GlSearchBoxByType, GlFormRadio, GlFormSelect } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardAddNewColumn from 'ee/boards/components/board_add_new_column.vue'; import BoardAddNewColumn, { listTypeInfo } from 'ee/boards/components/board_add_new_column.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import defaultState from '~/boards/stores/state'; import defaultState from '~/boards/stores/state';
import { mockAssignees, mockLists } from '../mock_data'; import { mockAssignees, mockLists, mockIterations } from '../mock_data';
const mockLabelList = mockLists[1]; const mockLabelList = mockLists[1];
...@@ -32,6 +32,7 @@ describe('BoardAddNewColumn', () => { ...@@ -32,6 +32,7 @@ describe('BoardAddNewColumn', () => {
selectedId, selectedId,
labels = [], labels = [],
assignees = [], assignees = [],
iterations = [],
getListByTypeId = jest.fn(), getListByTypeId = jest.fn(),
actions = {}, actions = {},
} = {}) => { } = {}) => {
...@@ -61,10 +62,15 @@ describe('BoardAddNewColumn', () => { ...@@ -61,10 +62,15 @@ describe('BoardAddNewColumn', () => {
labelsLoading: false, labelsLoading: false,
assignees, assignees,
assigneesLoading: false, assigneesLoading: false,
iterations,
iterationsLoading: false,
}, },
}), }),
provide: { provide: {
scopedLabelsAvailable: true, scopedLabelsAvailable: true,
milestoneListsAvailable: true,
assigneeListsAvailable: true,
iterationListsAvailable: true,
}, },
}), }),
); );
...@@ -175,9 +181,9 @@ describe('BoardAddNewColumn', () => { ...@@ -175,9 +181,9 @@ describe('BoardAddNewColumn', () => {
it('sets assignee placeholder text in form', async () => { it('sets assignee placeholder text in form', async () => {
expect(findForm().props()).toMatchObject({ expect(findForm().props()).toMatchObject({
formDescription: BoardAddNewColumn.i18n.assigneeListDescription, formDescription: listTypeInfo.assignee.formDescription,
searchLabel: BoardAddNewColumn.i18n.selectAssignee, searchLabel: listTypeInfo.assignee.searchLabel,
searchPlaceholder: BoardAddNewColumn.i18n.searchAssignees, searchPlaceholder: listTypeInfo.assignee.searchPlaceholder,
}); });
}); });
...@@ -193,4 +199,35 @@ describe('BoardAddNewColumn', () => { ...@@ -193,4 +199,35 @@ describe('BoardAddNewColumn', () => {
}); });
}); });
}); });
describe('iteration list', () => {
beforeEach(async () => {
mountComponent({
iterations: mockIterations,
actions: {
fetchIterations: jest.fn(),
},
});
listTypeSelect().vm.$emit('change', ListType.iteration);
await nextTick();
});
it('sets iteration placeholder text in form', async () => {
expect(findForm().props()).toMatchObject({
formDescription: listTypeInfo.iteration.formDescription,
searchLabel: listTypeInfo.iteration.searchLabel,
searchPlaceholder: listTypeInfo.iteration.searchPlaceholder,
});
});
it('shows list of iterations', () => {
const itemList = wrapper.findAllComponents(GlFormRadio);
expect(itemList).toHaveLength(mockIterations.length);
expect(itemList.at(0).attributes('value')).toBe(mockIterations[0].id);
expect(itemList.at(1).attributes('value')).toBe(mockIterations[1].id);
});
});
}); });
...@@ -104,6 +104,17 @@ export const mockMilestones = [ ...@@ -104,6 +104,17 @@ export const mockMilestones = [
}, },
]; ];
export const mockIterations = [
{
id: 'gid://gitlab/Iteration/1',
title: 'Iteration 1',
},
{
id: 'gid://gitlab/Iteration/2',
title: 'Iteration 2',
},
];
const labels = [ const labels = [
{ {
id: 'gid://gitlab/GroupLabel/5', id: 'gid://gitlab/GroupLabel/5',
......
...@@ -1270,6 +1270,77 @@ describe('fetchMilestones', () => { ...@@ -1270,6 +1270,77 @@ describe('fetchMilestones', () => {
}); });
}); });
describe('fetchIterations', () => {
const queryResponse = {
data: {
group: {
iterations: {
nodes: mockMilestones,
},
},
},
};
const queryErrors = {
data: {
group: {
errors: ['You cannot view these iterations'],
iterations: {},
},
},
};
function createStore({
state = {
boardType: 'group',
fullPath: 'gitlab-org/gitlab',
iterations: [],
iterationsLoading: false,
},
} = {}) {
return new Vuex.Store({
state,
mutations,
});
}
it('sets iterationsLoading to true', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchIterations(store);
expect(store.state.iterationsLoading).toBe(true);
});
describe('success', () => {
it('sets state.iterations from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchIterations(store);
expect(store.state.iterationsLoading).toBe(false);
expect(store.state.iterations).toBe(mockMilestones);
});
});
describe('failure', () => {
it('throws an error and displays an error message', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchIterations(store)).rejects.toThrow();
expect(store.state.iterationsLoading).toBe(false);
expect(store.state.error).toBe('Failed to load iterations.');
});
});
});
describe('fetchAssignees', () => { describe('fetchAssignees', () => {
const queryResponse = { const queryResponse = {
data: { data: {
......
...@@ -3642,6 +3642,9 @@ msgstr "" ...@@ -3642,6 +3642,9 @@ msgstr ""
msgid "An issue title is required" msgid "An issue title is required"
msgstr "" msgstr ""
msgid "An iteration list displays issues in the selected iteration."
msgstr ""
msgid "An unauthenticated user" msgid "An unauthenticated user"
msgstr "" msgstr ""
...@@ -12728,6 +12731,9 @@ msgstr "" ...@@ -12728,6 +12731,9 @@ msgstr ""
msgid "Failed to load groups, users and deploy keys." msgid "Failed to load groups, users and deploy keys."
msgstr "" msgstr ""
msgid "Failed to load iterations."
msgstr ""
msgid "Failed to load labels. Please try again." msgid "Failed to load labels. Please try again."
msgstr "" msgstr ""
...@@ -26554,6 +26560,9 @@ msgstr "" ...@@ -26554,6 +26560,9 @@ msgstr ""
msgid "Search forks" msgid "Search forks"
msgstr "" msgstr ""
msgid "Search iterations"
msgstr ""
msgid "Search labels" msgid "Search labels"
msgstr "" msgstr ""
......
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