Commit 83babf6a authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '231389-allow-users-to-edit-and-manage-an-existing-epic-board' into 'master'

Epic Boards - Update board scope [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!55815
parents edc9e862 26a68c2e
......@@ -107,7 +107,7 @@ export default {
};
},
computed: {
...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']),
...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
......@@ -182,7 +182,7 @@ export default {
groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
boardScopeMutationVariables() {
issueBoardScopeMutationVariables() {
/* eslint-disable @gitlab/require-i18n-strings */
return {
weight: this.board.weight,
......@@ -193,13 +193,18 @@ export default {
this.board.milestone?.id || this.board.milestone?.id === 0
? convertToGraphQLId('Milestone', this.board.milestone.id)
: null,
labelIds: this.board.labels.map(fullLabelId),
iterationId: this.board.iteration_id
? convertToGraphQLId('Iteration', this.board.iteration_id)
: null,
};
/* eslint-enable @gitlab/require-i18n-strings */
},
boardScopeMutationVariables() {
return {
labelIds: this.board.labels.map(fullLabelId),
...(this.isIssueBoard && this.issueBoardScopeMutationVariables),
};
},
mutationVariables() {
return {
...this.baseMutationVariables,
......@@ -324,7 +329,7 @@ export default {
/>
<board-scope
v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard"
v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
......
......@@ -15,7 +15,8 @@ export default {
props: {
boardsStore: {
type: Object,
required: true,
required: false,
default: null,
},
canAdminList: {
type: Boolean,
......@@ -26,11 +27,6 @@ export default {
required: true,
},
},
data() {
return {
state: this.boardsStore.state,
};
},
computed: {
buttonText() {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
......@@ -42,7 +38,9 @@ export default {
methods: {
showPage() {
eventHub.$emit('showBoardModal', formType.edit);
return this.boardsStore.showPage(formType.edit);
if (this.boardsStore) {
this.boardsStore.showPage(formType.edit);
}
},
},
};
......
......@@ -2,14 +2,15 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ConfigToggle from './components/config_toggle.vue';
export default (boardsStore) => {
export default (boardsStore = undefined) => {
const el = document.querySelector('.js-board-config');
if (!el) {
return;
}
gl.boardConfigToggle = new Vue({
// eslint-disable-next-line no-new
new Vue({
el,
render(h) {
return h(ConfigToggle, {
......
import { find } from 'lodash';
import { BoardType, inactiveId } from '../constants';
import { BoardType, inactiveId, issuableTypes } from '../constants';
export default {
isGroupBoard: (state) => state.boardType === BoardType.group,
......@@ -44,6 +44,10 @@ export default {
return find(state.boardLists, (l) => l.title === title);
},
isIssueBoard: (state) => {
return state.issuableType === issuableTypes.issue;
},
isEpicBoard: () => {
return false;
},
......
......@@ -313,7 +313,11 @@ export default class LabelsSelect {
return;
}
if ($('html').hasClass('issue-boards-page')) {
if (
$('html')
.attr('class')
.match(/issue-boards-page|epic-boards-page/)
) {
return;
}
if ($dropdown.hasClass('js-multiselect')) {
......
......@@ -281,6 +281,7 @@ module ApplicationHelper
def page_class
class_names = []
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
class_names << 'epic-boards-page' if current_controller?(:epic_boards)
class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class
......
......@@ -4,7 +4,9 @@
/* eslint-disable @gitlab/no-runtime-template-compiler */
import { mapGetters } from 'vuex';
import BoardFormFoss from '~/boards/components/board_form.vue';
import { fullEpicBoardId } from '../boards_util';
import createEpicBoardMutation from '../graphql/epic_board_create.mutation.graphql';
import updateEpicBoardMutation from '../graphql/epic_board_update.mutation.graphql';
export default {
extends: BoardFormFoss,
......@@ -13,11 +15,16 @@ export default {
epicBoardCreateQuery() {
return createEpicBoardMutation;
},
currentEpicBoardMutation() {
return this.board.id ? updateEpicBoardMutation : createEpicBoardMutation;
},
mutationVariables() {
// TODO: Epic board scope will be added in a future iteration: https://gitlab.com/gitlab-org/gitlab/-/issues/231389
const { board } = this;
return {
...this.baseMutationVariables,
...(this.scopedIssueBoardFeatureEnabled && !this.isEpicBoard
...(board.id && this.isEpicBoard && { id: fullEpicBoardId(board.id) }),
...(this.scopedIssueBoardFeatureEnabled || this.isEpicBoard
? this.boardScopeMutationVariables
: {}),
};
......@@ -27,9 +34,12 @@ export default {
epicBoardCreateResponse(data) {
return data.epicBoardCreate.epicBoard.webPath;
},
epicBoardUpdateResponse(data) {
return data.epicBoardUpdate.epicBoard.webPath;
},
async createOrUpdateBoard() {
const response = await this.$apollo.mutate({
mutation: this.isEpicBoard ? this.epicBoardCreateQuery : this.currentMutation,
mutation: this.isEpicBoard ? this.currentEpicBoardMutation : this.currentMutation,
variables: { input: this.mutationVariables },
});
......@@ -39,7 +49,9 @@ export default {
: this.boardCreateResponse(response.data);
}
return this.boardUpdateResponse(response.data);
return this.isEpicBoard
? this.epicBoardUpdateResponse(response.data)
: this.boardUpdateResponse(response.data);
},
},
};
......
<script>
import { mapGetters } from 'vuex';
import ListLabel from '~/boards/models/label';
import { __ } from '~/locale';
import BoardLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
......@@ -66,6 +67,7 @@ export default {
},
computed: {
...mapGetters(['isIssueBoard']),
expandButtonText() {
return this.expanded ? __('Collapse') : __('Expand');
},
......@@ -110,6 +112,7 @@ export default {
</p>
<div v-if="!collapseScope || expanded">
<board-milestone-select
v-if="isIssueBoard"
:board="board"
:group-id="groupId"
:project-id="projectId"
......@@ -117,6 +120,7 @@ export default {
/>
<board-scope-current-iteration
v-if="isIssueBoard"
:can-admin-board="canAdminBoard"
:iteration-id="board.iteration_id"
@set-iteration="$emit('set-iteration', $event)"
......@@ -136,6 +140,7 @@ export default {
>
<assignee-select
v-if="isIssueBoard"
:board="board"
:selected="board.assignee"
:can-edit="canAdminBoard"
......@@ -150,6 +155,7 @@ export default {
<!-- eslint-disable vue/no-mutating-props -->
<board-weight-select
v-if="isIssueBoard"
v-model="board.weight"
:board="board"
:weights="weights"
......
mutation updateEpicBoard($input: EpicBoardUpdateInput!) {
epicBoardUpdate(input: $input) {
epicBoard {
id
webPath
}
errors
}
}
......@@ -5,6 +5,7 @@ query ListEpics(
$fullPath: ID!
$boardId: BoardsEpicBoardID!
$id: BoardsEpicListID
$filters: EpicFilters
$after: String
$first: Int
) {
......@@ -13,7 +14,7 @@ query ListEpics(
lists(id: $id) {
nodes {
id
epics(first: $first, after: $after) {
epics(first: $first, after: $after, filters: $filters) {
edges {
node {
...EpicNode
......
......@@ -115,7 +115,7 @@ const fetchAndFormatListEpics = (state, extraVariables) => {
export default {
...actionsCE,
setFilters: ({ commit, dispatch }, filters) => {
setFilters: ({ commit, dispatch, getters }, filters) => {
const filterParams = pick(filters, [
'assigneeUsername',
'authorUsername',
......@@ -128,7 +128,10 @@ export default {
'weight',
]);
filterParams.not = transformNotFilters(filters);
// Temporarily disabled until negated filters are supported for epic boards
if (!getters.isEpicBoard) {
filterParams.not = transformNotFilters(filters);
}
if (filters.groupBy === GroupByParamType.epic) {
dispatch('setEpicSwimlanes');
......@@ -140,7 +143,7 @@ export default {
} else if (filterParams.epicId) {
filterParams.epicId = fullEpicId(filterParams.epicId);
}
if (filterParams.not.epicId) {
if (!getters.isEpicBoard && filterParams.not.epicId) {
filterParams.not.epicId = fullEpicId(filterParams.not.epicId);
}
......
......@@ -4,14 +4,16 @@
/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { transformBoardConfig } from 'ee_component/boards/boards_util';
import BoardSidebar from 'ee_component/boards/components/board_sidebar';
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 BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import boardConfigToggle from '~/boards/config_toggle';
import { issuableTypes } from '~/boards/constants';
import mountMultipleBoardsSwitcher from '~/boards/mount_multiple_boards_switcher';
import store from '~/boards/stores';
......@@ -19,6 +21,7 @@ import createDefaultClient from '~/lib/graphql';
import '~/boards/filters/due_date_filters';
import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
Vue.use(VueApollo);
......@@ -87,6 +90,9 @@ export default () => {
detailIssueVisible: false,
};
},
computed: {
...mapState(['boardConfig']),
},
created() {
this.setInitialBoardData({
boardId: $boardApp.dataset.boardId,
......@@ -110,6 +116,13 @@ export default () => {
});
},
mounted() {
const boardConfigPath = transformBoardConfig(this.boardConfig);
if (boardConfigPath !== '') {
const filterPath = window.location.search ? `${window.location.search}&` : '?';
updateHistory({
url: `${filterPath}${transformBoardConfig(this.boardConfig)}`,
});
}
this.performSearch();
},
methods: {
......@@ -136,6 +149,7 @@ export default () => {
}
toggleLabels();
boardConfigToggle();
mountMultipleBoardsSwitcher({
fullPath: $boardApp.dataset.fullPath,
......
......@@ -33,7 +33,7 @@ module Boards
end
def scoped?
false
labels.any?
end
def milestone_id
......
......@@ -20,6 +20,9 @@ RSpec.describe 'epic boards', :js do
let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') }
let_it_be(:epic3) { create(:epic, group: group, labels: [label2], title: 'Epic3') }
let(:edit_board) { find('.btn', text: 'Edit board') }
let(:view_scope) { find('.btn', text: 'View scope') }
context 'display epics in board' do
before do
stub_licensed_features(epics: true)
......@@ -103,6 +106,25 @@ RSpec.describe 'epic boards', :js do
it "shows 'Create list' button" do
expect(page).to have_selector('[data-testid="boards-create-list"]')
end
it 'creates board filtering by one label' do
create_board_label(label.title)
expect(page).to have_selector('.board-card', count: 1)
end
it 'adds label to board scope and filters epics' do
label_title = label.title
update_board_label(label_title)
aggregate_failures do
expect(page).to have_selector('.board-card', count: 1)
expect(page).to have_content('Epic1')
expect(page).not_to have_content('Epic2')
expect(page).not_to have_content('Epic3')
end
end
end
context 'when user cannot admin epic boards' do
......@@ -116,6 +138,21 @@ RSpec.describe 'epic boards', :js do
it "does not show 'Create list'" do
expect(page).not_to have_selector('[data-testid="boards-create-list"]')
end
it 'can view board scope' do
view_scope.click
page.within('.modal') do
aggregate_failures do
expect(find('.modal-header')).to have_content('Board scope')
expect(page).not_to have_content('Board name')
expect(page).not_to have_link('Edit')
expect(page).not_to have_button('Edit')
expect(page).not_to have_button('Save')
expect(page).not_to have_button('Cancel')
end
end
end
end
def visit_epic_boards_page
......@@ -139,4 +176,70 @@ RSpec.describe 'epic boards', :js do
list_to_index: list_to_index,
perform_drop: perform_drop)
end
def click_value(filter, value)
page.within(".#{filter}") do
click_button 'Edit'
if value.is_a?(Array)
value.each { |value| click_link value }
else
click_link value
end
end
end
def click_on_create_new_board
page.within '.js-boards-selector' do
find('.dropdown-menu-toggle').click
wait_for_requests
click_button 'Create new board'
end
end
def create_board_label(label_title)
create_board_scope('labels', label_title)
end
def create_board_scope(filter, value)
click_on_create_new_board
find('#board-new-name').set 'test'
click_button 'Expand'
click_value(filter, value)
click_on_board_modal
click_button 'Create board'
wait_for_requests
expect(page).not_to have_selector('.board-list-loading')
end
def update_board_scope(filter, value)
edit_board.click
click_value(filter, value)
click_on_board_modal
click_button 'Save changes'
wait_for_requests
expect(page).not_to have_selector('.board-list-loading')
end
def update_board_label(label_title)
update_board_scope('labels', label_title)
end
# Click on modal to make sure the dropdown is closed (e.g. label scenario)
#
def click_on_board_modal
find('.board-config-modal .modal-content').click
end
end
......@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import BoardForm from 'ee/boards/components/board_form.vue';
import createEpicBoardMutation from 'ee/boards/graphql/epic_board_create.mutation.graphql';
import updateEpicBoardMutation from 'ee/boards/graphql/epic_board_update.mutation.graphql';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -55,6 +56,7 @@ describe('BoardForm', () => {
const createStore = () => {
return new Vuex.Store({
getters: {
isIssueBoard: () => false,
isEpicBoard: () => true,
isGroupBoard: () => true,
isProjectBoard: () => false,
......@@ -181,4 +183,48 @@ describe('BoardForm', () => {
});
});
});
describe('when editing an epic board', () => {
it('calls GraphQL mutation with correct parameters', async () => {
mutate = jest.fn().mockResolvedValue({
data: {
epicBoardUpdate: {
epicBoard: { id: 'gid://gitlab/Boards::EpicBoard/321', webPath: 'test-path' },
},
},
});
window.location = new URL('https://test/boards/1');
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
expect(mutate).toHaveBeenCalledWith({
mutation: updateEpicBoardMutation,
variables: {
input: expect.objectContaining({
id: `gid://gitlab/Boards::EpicBoard/${currentBoard.id}`,
}),
},
});
await waitForPromises();
expect(visitUrl).toHaveBeenCalledWith('test-path');
});
it('shows an error flash if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true, currentPage: formType.edit });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
expect(mutate).toHaveBeenCalled();
await waitForPromises();
expect(visitUrl).not.toHaveBeenCalled();
expect(createFlash).toHaveBeenCalled();
});
});
});
import { mount } from '@vue/test-utils';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardScope from 'ee/boards/components/board_scope.vue';
import { TEST_HOST } from 'helpers/test_constants';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('BoardScope', () => {
let wrapper;
let vm;
......@@ -18,8 +22,21 @@ describe('BoardScope', () => {
labelsWebUrl: `${TEST_HOST}/-/labels`,
};
const createStore = () => {
return new Vuex.Store({
getters: {
isIssueBoard: () => true,
isEpicBoard: () => false,
},
});
};
const store = createStore();
wrapper = mount(BoardScope, {
localVue,
propsData,
store,
});
({ vm } = wrapper);
......
......@@ -122,4 +122,21 @@ describe('EE Boards Store Getters', () => {
).toBeUndefined();
});
});
describe('isEpicBoard', () => {
it.each`
issuableType | expected
${'epic'} | ${true}
${'issue'} | ${false}
`(
'returns $expected when issuableType on state is $issuableType',
({ issuableType, expected }) => {
const state = {
issuableType,
};
expect(getters.isEpicBoard(state)).toBe(expected);
},
);
});
});
......@@ -177,4 +177,31 @@ describe('Boards - Getters', () => {
expect(getters.activeGroupProjects(state)).toEqual([mockGroupProject1]);
});
});
describe('isIssueBoard', () => {
it.each`
issuableType | expected
${'issue'} | ${true}
${'epic'} | ${false}
`(
'returns $expected when issuableType on state is $issuableType',
({ issuableType, expected }) => {
const state = {
issuableType,
};
expect(getters.isIssueBoard(state)).toBe(expected);
},
);
});
describe('isEpicBoard', () => {
afterEach(() => {
window.gon = { features: {} };
});
it('returns false', () => {
expect(getters.isEpicBoard()).toBe(false);
});
});
});
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