Commit b3b7d2c1 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'winh-multiple-issueboards-core' into 'master'

Move multiple issue boards frontend to core (CE-backport)

See merge request gitlab-org/gitlab-ce!30503
parents 558f41dd 7e87990e
<script>
import Flash from '~/flash';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { visitUrl } from '~/lib/utils/url_utility';
import boardsStore from '~/boards/stores/boards_store';
const boardDefaults = {
id: false,
name: '',
labels: [],
milestone_id: undefined,
assignee: {},
assignee_id: undefined,
weight: null,
};
export default {
components: {
BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
DeprecatedModal,
},
props: {
canAdminBoard: {
type: Boolean,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
scopedIssueBoardFeatureEnabled: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: false,
default: 0,
},
groupId: {
type: Number,
required: false,
default: 0,
},
weights: {
type: Array,
required: false,
default: () => [],
},
enableScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
},
data() {
return {
board: { ...boardDefaults, ...this.currentBoard },
currentBoard: boardsStore.state.currentBoard,
currentPage: boardsStore.state.currentPage,
isLoading: false,
};
},
computed: {
isNewForm() {
return this.currentPage === 'new';
},
isDeleteForm() {
return this.currentPage === 'delete';
},
isEditForm() {
return this.currentPage === 'edit';
},
isVisible() {
return this.currentPage !== '';
},
buttonText() {
if (this.isNewForm) {
return 'Create board';
}
if (this.isDeleteForm) {
return 'Delete';
}
return 'Save changes';
},
buttonKind() {
if (this.isNewForm) {
return 'success';
}
if (this.isDeleteForm) {
return 'danger';
}
return 'info';
},
title() {
if (this.isNewForm) {
return 'Create new board';
}
if (this.isDeleteForm) {
return 'Delete board';
}
if (this.readonly) {
return 'Board scope';
}
return 'Edit board';
},
readonly() {
return !this.canAdminBoard;
},
submitDisabled() {
return this.isLoading || this.board.name.length === 0;
},
},
mounted() {
this.resetFormState();
if (this.$refs.name) {
this.$refs.name.focus();
}
},
methods: {
submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
gl.boardService
.deleteBoard(this.currentBoard)
.then(() => {
visitUrl(boardsStore.rootPath);
})
.catch(() => {
Flash('Failed to delete board. Please try again.');
this.isLoading = false;
});
} else {
gl.boardService
.createBoard(this.board)
.then(resp => resp.data)
.then(data => {
visitUrl(data.board_path);
})
.catch(() => {
Flash('Unable to save your changes. Please try again.');
this.isLoading = false;
});
}
},
cancel() {
boardsStore.showPage('');
},
resetFormState() {
if (this.isNewForm) {
// Clear the form when we open the "New board" modal
this.board = { ...boardDefaults };
} else if (this.currentBoard && Object.keys(this.currentBoard).length) {
this.board = { ...boardDefaults, ...this.currentBoard };
}
},
},
};
</script>
<template>
<deprecated-modal
v-show="isVisible"
:hide-footer="readonly"
:title="title"
:primary-button-label="buttonText"
:kind="buttonKind"
:submit-disabled="submitDisabled"
modal-dialog-class="board-config-modal"
@cancel="cancel"
@submit="submit"
>
<template slot="body">
<p v-if="isDeleteForm">Are you sure you want to delete this board?</p>
<form v-else class="js-board-config-modal" @submit.prevent>
<div v-if="!readonly" class="append-bottom-20">
<label class="form-section-title label-bold" for="board-new-name"> Board name </label>
<input
id="board-new-name"
ref="name"
v-model="board.name"
class="form-control"
type="text"
placeholder="Enter board name"
@keyup.enter="submit"
/>
</div>
<board-scope
v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
:milestone-path="milestonePath"
:labels-path="labelsPath"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
:project-id="projectId"
:group-id="groupId"
:weights="weights"
/>
</form>
</template>
</deprecated-modal>
</template>
<script>
import { throttle } from 'underscore';
import {
GlLoadingIcon,
GlSearchBoxByType,
GlDropdown,
GlDropdownDivider,
GlDropdownHeader,
GlDropdownItem,
} from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import httpStatusCodes from '~/lib/utils/http_status';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
name: 'BoardsSelector',
components: {
Icon,
BoardForm,
GlLoadingIcon,
GlSearchBoxByType,
GlDropdown,
GlDropdownDivider,
GlDropdownHeader,
GlDropdownItem,
},
props: {
currentBoard: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
throttleDuration: {
type: Number,
default: 200,
},
boardBaseUrl: {
type: String,
required: true,
},
hasMissingBoards: {
type: Boolean,
required: true,
},
canAdminBoard: {
type: Boolean,
required: true,
},
multipleIssueBoardsAvailable: {
type: Boolean,
required: true,
},
labelsPath: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
groupId: {
type: Number,
required: true,
},
scopedIssueBoardFeatureEnabled: {
type: Boolean,
required: true,
},
weights: {
type: Array,
required: true,
},
enabledScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
},
data() {
return {
loading: true,
hasScrollFade: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
state: boardsStore.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
maxPosition: 0,
store: boardsStore,
filterTerm: '',
};
},
computed: {
currentPage() {
return this.state.currentPage;
},
filteredBoards() {
return this.boards.filter(board =>
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
reload: {
get() {
return this.state.reload;
},
set(newValue) {
this.state.reload = newValue;
},
},
board() {
return this.state.currentBoard;
},
showDelete() {
return this.boards.length > 1;
},
scrollFadeClass() {
return {
'fade-out': !this.hasScrollFade,
};
},
showRecentSection() {
return (
this.recentBoards.length &&
this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
!this.filterTerm.length
);
},
},
watch: {
filteredBoards() {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
reload() {
if (this.reload) {
this.boards = [];
this.recentBoards = [];
this.loading = true;
this.reload = false;
this.loadBoards(false);
}
},
},
created() {
boardsStore.setCurrentBoard(this.currentBoard);
},
methods: {
showPage(page) {
boardsStore.showPage(page);
},
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
return;
}
const recentBoardsPromise = new Promise((resolve, reject) =>
gl.boardService
.recentBoards()
.then(resolve)
.catch(err => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
resolve({ data: [] }); // recent boards are empty
return;
}
reject(err);
}),
);
Promise.all([gl.boardService.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false;
this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
this.setScrollFade();
})
.catch(() => {
this.loading = false;
});
},
isScrolledUp() {
const { content } = this.$refs;
const currentPosition = this.contentClientHeight + content.scrollTop;
return content && currentPosition < this.maxPosition;
},
initScrollFade() {
this.scrollFadeInitialized = true;
const { content } = this.$refs;
this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight;
},
setScrollFade() {
if (!this.scrollFadeInitialized) this.initScrollFade();
this.hasScrollFade = this.isScrolledUp();
},
},
};
</script>
<template>
<div class="boards-switcher js-boards-selector append-right-10">
<span class="boards-selector-wrapper js-boards-selector-wrapper">
<gl-dropdown
toggle-class="dropdown-menu-toggle js-dropdown-toggle"
menu-class="flex-column dropdown-extended-height"
:text="board.name"
@show="loadBoards"
>
<div>
<div class="dropdown-title mb-0" @mousedown.prevent>
{{ s__('IssueBoards|Switch board') }}
</div>
</div>
<gl-dropdown-header class="mt-0">
<gl-search-box-by-type ref="searchBox" v-model="filterTerm" />
</gl-dropdown-header>
<div
v-if="!loading"
ref="content"
class="dropdown-content flex-fill"
@scroll.passive="throttledSetScrollFade"
>
<gl-dropdown-item
v-show="filteredBoards.length === 0"
class="no-pointer-events text-secondary"
>
{{ s__('IssueBoards|No matching boards found') }}
</gl-dropdown-item>
<h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
{{ __('Recent') }}
</h6>
<template v-if="showRecentSection">
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${recentBoard.id}`"
>
{{ recentBoard.name }}
</gl-dropdown-item>
</template>
<hr v-if="showRecentSection" class="my-1" />
<h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
{{ __('All') }}
</h6>
<gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${otherBoard.id}`"
>
{{ otherBoard.name }}
</gl-dropdown-item>
<gl-dropdown-item v-if="hasMissingBoards" class="small unclickable">
{{
s__(
'IssueBoards|Some of your boards are hidden, activate a license to see them again.',
)
}}
</gl-dropdown-item>
</div>
<div
v-show="filteredBoards.length > 0"
class="dropdown-content-faded-mask"
:class="scrollFadeClass"
></div>
<gl-loading-icon v-if="loading" />
<div v-if="canAdminBoard">
<gl-dropdown-divider />
<gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')">
{{ s__('IssueBoards|Create new board') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="showDelete"
class="text-danger"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
</gl-dropdown-item>
</div>
</gl-dropdown>
<board-form
v-if="currentPage"
:milestone-path="milestonePath"
:labels-path="labelsPath"
:project-id="projectId"
:group-id="groupId"
:can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
</span>
</div>
</template>
import $ from 'jquery';
import Vue from 'vue';
import mountMultipleBoardsSwitcher from 'ee_else_ce/boards/mount_multiple_boards_switcher';
import Flash from '~/flash';
import { __ } from '~/locale';
import './models/label';
......@@ -31,6 +30,7 @@ import {
} from '~/lib/utils/common_utils';
import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
import toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
let issueBoardsApp;
......
// this will be moved from EE to CE as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/53811
export default () => {};
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
export default () => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
el: boardsSwitcherElement,
components: {
BoardsSelector,
},
data() {
const { dataset } = boardsSwitcherElement;
const boardsSelectorProps = {
...dataset,
currentBoard: JSON.parse(dataset.currentBoard),
hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
canAdminBoard: parseBoolean(dataset.canAdminBoard),
multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
projectId: Number(dataset.projectId),
groupId: Number(dataset.groupId),
scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
weights: JSON.parse(dataset.weights),
};
return { boardsSelectorProps };
},
render(createElement) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
});
},
});
};
- parent = board.parent
- milestone_filter_opts = { format: :json }
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
- weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : []
#js-multiple-boards-switcher.inline.boards-switcher{ data: { current_board: current_board_json.to_json,
milestone_path: milestones_filter_path(milestone_filter_opts),
board_base_url: board_base_url,
has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s,
can_admin_board: can?(current_user, :admin_board, parent).to_s,
multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s,
labels_path: labels_filter_path_with_defaults(only_group_labels: true, include_descendant_groups: true),
project_id: @project&.id,
group_id: @group&.id,
scoped_issue_board_feature_enabled: Gitlab.ee? && parent.feature_available?(:scoped_issue_board) ? 'true' : 'false',
weights: weights.to_json } }
......@@ -6,7 +6,7 @@
.issues-filters{ class: ("w-100" if type == :boards_modal) }
.issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards
= render_if_exists "shared/boards/switcher", board: board
= render "shared/boards/switcher", board: board
= form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
......
---
title: Move multiple issue boards to core
merge_request: 30503
author:
type: changed
......@@ -5717,6 +5717,21 @@ msgstr ""
msgid "IssueBoards|Boards"
msgstr ""
msgid "IssueBoards|Create new board"
msgstr ""
msgid "IssueBoards|Delete board"
msgstr ""
msgid "IssueBoards|No matching boards found"
msgstr ""
msgid "IssueBoards|Some of your boards are hidden, activate a license to see them again."
msgstr ""
msgid "IssueBoards|Switch board"
msgstr ""
msgid "IssueTracker|Bugzilla issue tracker"
msgstr ""
......@@ -8698,6 +8713,9 @@ msgstr ""
msgid "Receive notifications about your own activity"
msgstr ""
msgid "Recent"
msgstr ""
msgid "Recent Project Activity"
msgstr ""
......
import $ from 'jquery';
import Vue from 'vue';
import boardsStore from '~/boards/stores/boards_store';
import boardForm from '~/boards/components/board_form.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('board_form.vue', () => {
const props = {
canAdminBoard: false,
labelsPath: `${gl.TEST_HOST}/labels/path`,
milestonePath: `${gl.TEST_HOST}/milestone/path`,
};
let vm;
beforeEach(() => {
spyOn($, 'ajax');
boardsStore.state.currentPage = 'edit';
const Component = Vue.extend(boardForm);
vm = mountComponent(Component, props);
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('cancel', () => {
it('resets currentPage', done => {
vm.cancel();
Vue.nextTick()
.then(() => {
expect(boardsStore.state.currentPage).toBe('');
})
.then(done)
.catch(done.fail);
});
});
});
describe('buttons', () => {
it('cancel button triggers cancel()', done => {
spyOn(vm, 'cancel');
Vue.nextTick()
.then(() => {
const cancelButton = vm.$el.querySelector('button[data-dismiss="modal"]');
cancelButton.click();
expect(vm.cancel).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import BoardService from '~/boards/services/board_service';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { TEST_HOST } from 'spec/test_constants';
import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
return new Array(n).fill().map((board, id) => {
const name = `board${id}`;
return {
id,
name,
};
});
}
describe('BoardsSelector', () => {
let vm;
let allBoardsResponse;
let recentBoardsResponse;
let fillSearchBox;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
beforeEach(done => {
setFixtures('<div class="js-boards-selector"></div>');
window.gl = window.gl || {};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '',
bulkUpdatePath: '',
boardId: '',
});
window.gl.boardService = new BoardService();
allBoardsResponse = Promise.resolve({
data: boards,
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
spyOn(BoardService.prototype, 'allBoards').and.returnValue(allBoardsResponse);
spyOn(BoardService.prototype, 'recentBoards').and.returnValue(recentBoardsResponse);
const Component = Vue.extend(BoardsSelector);
vm = mountComponent(
Component,
{
throttleDuration,
currentBoard: {
id: 1,
name: 'Development',
milestone_id: null,
weight: null,
assignee_id: null,
labels: [],
},
milestonePath: `${TEST_HOST}/milestone/path`,
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
multipleIssueBoardsAvailable: true,
labelsPath: `${TEST_HOST}/labels/path`,
projectId: 42,
groupId: 19,
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
document.querySelector('.js-boards-selector'),
);
vm.$el.querySelector('.js-dropdown-toggle').click();
Promise.all([allBoardsResponse, recentBoardsResponse])
.then(() => vm.$nextTick())
.then(done)
.catch(done.fail);
fillSearchBox = filterTerm => {
const { searchBox } = vm.$refs;
const searchBoxInput = searchBox.$el.querySelector('input');
searchBoxInput.value = filterTerm;
searchBoxInput.dispatchEvent(new Event('input'));
};
});
afterEach(() => {
vm.$destroy();
window.gl.boardService = undefined;
});
describe('filtering', () => {
it('shows all boards without filtering', done => {
vm.$nextTick()
.then(() => {
const dropdownItem = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItem.length).toBe(boards.length + recentBoards.length);
})
.then(done)
.catch(done.fail);
});
it('shows only matching boards when filtering', done => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm);
vm.$nextTick()
.then(() => {
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItems.length).toBe(expectedCount);
})
.then(done)
.catch(done.fail);
});
it('shows message if there are no matching boards', done => {
fillSearchBox('does not exist');
vm.$nextTick()
.then(() => {
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItems.length).toBe(0);
expect(vm.$el).toContainText('No matching boards found');
})
.then(done)
.catch(done.fail);
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', done => {
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 2; // Recent + All
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when boards are less than 10', done => {
spyOn(vm, 'initScrollFade');
spyOn(vm, 'setScrollFade');
vm.$nextTick()
.then(() => {
vm.boards = vm.boards.slice(0, 5);
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when recentBoards api returns empty array', done => {
vm.$nextTick()
.then(() => {
vm.recentBoards = [];
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when search is active', done => {
fillSearchBox('Random string');
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
});
});
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