Commit 3d5d6d53 authored by Sean McGivern's avatar Sean McGivern

Merge branch '2518-saved-configuration-for-issue-board' into 'master'

Resolve "Saved configuration for issue board"

Closes #2518, #3792, #2093, and #2924

See merge request gitlab-org/gitlab-ee!2912
parents 58370a7d 7ca70402
......@@ -68,7 +68,6 @@ $(() => {
rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
detailIssue: Store.detail,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
defaultAvatar: $boardApp.dataset.defaultAvatar,
},
computed: {
......@@ -77,16 +76,6 @@ $(() => {
},
},
created () {
if (this.milestoneTitle) {
const milestoneTitleParam = `milestone_title=${this.milestoneTitle}`;
Store.filter.path = [milestoneTitleParam].concat(
Store.filter.path.split('&').filter(param => param.match(/^milestone_title=(.*)$/g) === null)
).join('&');
Store.updateFiltersUrl(true);
}
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint,
......@@ -102,7 +91,7 @@ $(() => {
eventHub.$off('updateTokens', this.updateTokens);
},
mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]);
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
this.filterManager.setup();
Store.disabled = this.disabled;
......@@ -146,6 +135,40 @@ $(() => {
},
});
const configEl = document.querySelector('.js-board-config');
if (configEl) {
gl.boardConfigToggle = new Vue({
el: configEl,
data() {
return {
canAdminList: convertPermissionToBoolean(
this.$options.el.dataset.canAdminList,
),
};
},
methods: {
showPage: page => gl.issueBoards.BoardsStore.showPage(page),
},
computed: {
buttonText() {
return this.canAdminList ? 'Edit board' : 'View scope';
},
},
template: `
<div class="prepend-left-10">
<button
class="btn btn-inverted"
type="button"
@click.prevent="showPage('edit')"
>
{{ buttonText }}
</button>
</div>
`,
});
}
gl.IssueBoardsModalAddBtn = new Vue({
mixins: [gl.issueBoards.ModalMixins],
el: document.getElementById('js-add-issues-btn'),
......
<script>
import UsersSelect from '~/users_select';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
props: {
anyUserText: {
type: String,
required: false,
default: 'Any user',
},
board: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
fieldName: {
type: String,
required: true,
},
groupId: {
type: Number,
required: false,
default: 0,
},
label: {
type: String,
required: true,
},
placeholderText: {
type: String,
required: false,
default: 'Select user',
},
projectId: {
type: Number,
required: false,
default: 0,
},
selected: {
type: Object,
required: true,
},
wrapperClass: {
type: String,
required: false,
default: '',
},
},
components: {
loadingIcon,
UserAvatarImage,
},
computed: {
hasValue() {
return this.selected.id > 0;
},
selectedId() {
return this.selected ? this.selected.id : null;
},
},
watch: {
selected() {
this.initSelect();
},
},
methods: {
initSelect() {
this.userDropdown = new UsersSelect(null, this.$refs.dropdown, {
handleClick: this.selectUser,
});
},
selectUser(user, isMarking) {
let assignee = user;
if (!isMarking) {
// correctly select "unassigned" in Assignee dropdown
assignee = {
id: undefined,
};
}
this.board.assignee_id = assignee.id;
this.board.assignee = assignee;
},
},
mounted() {
this.initSelect();
},
};
</script>
<template>
<div
class="block"
:class="wrapperClass"
>
<div class="title append-bottom-10">
{{ label }}
<button
v-if="canEdit"
type="button"
class="edit-link btn btn-blank pull-right"
>
Edit
</button>
</div>
<div class="value">
<div
v-if="hasValue"
class="media"
>
<div class="align-center">
<user-avatar-image
:img-src="selected.avatar_url"
:size="32"
/>
</div>
<div class="media-body">
<div class="bold author">
{{ selected.name }}
</div>
<div class="username">
@{{ selected.username }}
</div>
</div>
</div>
<div
v-else
class="text-secondary"
>
{{ anyUserText }}
</div>
</div>
<div
class="selectbox"
style="display: none"
>
<div class="dropdown">
<button
class="dropdown-menu-toggle wide"
ref="dropdown"
:data-field-name="fieldName"
:data-dropdown-title="placeholderText"
:data-any-user="anyUserText"
:data-group-id="groupId"
:data-project-id="projectId"
:data-selected="selectedId"
data-toggle="dropdown"
aria-expanded="false"
type="button"
>
<span class="dropdown-toggle-text">
{{ placeholderText }}
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down"
data-hidden="true"
/>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-user dropdown-menu-selectable dropdown-menu-author">
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
placeholder="Search"
type="search"
>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
data-hidden="true"
/>
<i
aria-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
role="button"
/>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</div>
</template>
<script>
/* global BoardService */
import Flash from '~/flash';
import PopupDialog from '~/vue_shared/components/popup_dialog.vue';
import BoardMilestoneSelect from './milestone_select.vue';
import BoardWeightSelect from './weight_select.vue';
import BoardLabelsSelect from './labels_select.vue';
import AssigneeSelect from './assignee_select.vue';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
const Store = gl.issueBoards.BoardsStore;
const boardDefaults = {
id: false,
name: '',
labels: [],
milestone_id: undefined,
assignee: {},
assignee_id: undefined,
weight: null,
};
export default {
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: String,
required: false,
},
},
data() {
return {
board: { ...boardDefaults, ...this.currentBoard },
expanded: false,
issue: {},
currentBoard: Store.state.currentBoard,
currentPage: Store.state.currentPage,
milestones: [],
milestoneDropdownOpen: false,
isLoading: false,
};
},
components: {
AssigneeSelect,
BoardLabelsSelect,
BoardMilestoneSelect,
BoardWeightSelect,
PopupDialog,
},
computed: {
isNewForm() {
return this.currentPage === 'new';
},
isDeleteForm() {
return this.currentPage === 'delete';
},
isEditForm() {
return this.currentPage === 'edit';
},
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';
},
expandButtonText() {
return this.expanded ? 'Collapse' : 'Expand';
},
collapseScope() {
return this.isNewForm;
},
readonly() {
return !this.canAdminBoard;
},
weightsArray() {
return JSON.parse(this.weights);
},
submitDisabled() {
return this.isLoading || this.board.name.length === 0;
},
},
methods: {
submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
gl.boardService.deleteBoard(this.currentBoard)
.then(() => {
gl.utils.visitUrl(Store.rootPath);
})
.catch(() => {
Flash('Failed to delete board. Please try again.');
this.isLoading = false;
});
} else {
gl.boardService.createBoard(this.board)
.then(resp => resp.json())
.then((data) => {
gl.utils.visitUrl(data.board_path);
})
.catch(() => {
Flash('Unable to save your changes. Please try again.');
this.isLoading = false;
});
}
},
cancel() {
Store.state.currentPage = '';
},
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 };
}
},
},
mounted() {
this.resetFormState();
if (this.$refs.name) {
this.$refs.name.focus();
}
},
};
</script>
<template>
<popup-dialog
v-show="currentPage"
modal-dialog-class="board-config-modal"
:hide-footer="readonly"
:title="title"
:primary-button-label="buttonText"
:kind="buttonKind"
:submit-disabled="submitDisabled"
@toggle="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-light"
for="board-new-name"
>
Board name
</label>
<input
ref="name"
class="form-control"
type="text"
id="board-new-name"
v-model="board.name"
@keyup.enter="submit"
placeholder="Enter board name"
>
</div>
<div v-if="scopedIssueBoardFeatureEnabled">
<div
v-if="canAdminBoard"
class="media append-bottom-10"
>
<label class="form-section-title label-light media-body">
Board scope
</label>
<button
type="button"
class="btn"
@click="expanded = !expanded"
v-if="collapseScope"
>
{{ expandButtonText }}
</button>
</div>
<p class="text-secondary append-bottom-10">
Board scope affects which issues are displayed for anyone who visits this board
</p>
<div v-if="!collapseScope || expanded">
<board-milestone-select
:board="board"
:milestone-path="milestonePath"
:can-edit="canAdminBoard"
/>
<board-labels-select
:board="board"
:can-edit="canAdminBoard"
:labels-path="labelsPath"
/>
<assignee-select
any-user-text="Any assignee"
:board="board"
field-name="assignee_id"
label="Assignee"
:selected="board.assignee"
:can-edit="canAdminBoard"
placeholder-text="Select assignee"
:project-id="projectId"
:group-id="groupId"
wrapper-class="assignee"
/>
<board-weight-select
:board="board"
:weights="weightsArray"
v-model="board.weight"
:can-edit="canAdminBoard"
/>
</div>
</div>
</form>
</template>
</popup-dialog>
</template>
/* global BoardService */
import Vue from 'vue';
import boardMilestoneSelect from './milestone_select';
import extraMilestones from '../mixins/extra_milestones';
(() => {
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
const Store = gl.issueBoards.BoardsStore;
gl.issueBoards.BoardSelectorForm = Vue.extend({
props: {
milestonePath: {
type: String,
required: true,
},
},
data() {
return {
board: {
id: false,
name: '',
milestone: extraMilestones[0],
milestone_id: extraMilestones[0].id,
},
currentBoard: Store.state.currentBoard,
currentPage: Store.state.currentPage,
milestones: [],
milestoneDropdownOpen: false,
extraMilestones,
};
},
components: {
boardMilestoneSelect,
},
mounted() {
if (this.currentBoard && Object.keys(this.currentBoard).length && this.currentPage !== 'new') {
this.board = Vue.util.extend({}, this.currentBoard);
}
},
computed: {
buttonText() {
if (this.currentPage === 'new') {
return 'Create';
}
return 'Save';
},
milestoneToggleText() {
return this.board.milestone.title || 'Milestone';
},
submitDisabled() {
if (this.currentPage !== 'milestone') {
return this.board.name === '';
}
return false;
},
},
methods: {
refreshPage() {
location.href = location.pathname;
},
loadMilestones(e) {
this.milestoneDropdownOpen = !this.milestoneDropdownOpen;
BoardService.loadMilestones.call(this);
if (this.milestoneDropdownOpen) {
this.$nextTick(() => {
const milestoneDropdown = this.$refs.milestoneDropdown;
const rect = e.target.getBoundingClientRect();
milestoneDropdown.style.width = `${rect.width}px`;
});
}
},
submit() {
gl.boardService.createBoard(this.board)
.then(resp => resp.json())
.then((data) => {
if (this.currentBoard && this.currentPage !== 'new') {
this.currentBoard.name = this.board.name;
if (this.currentPage === 'milestone') {
// We reload the page to make sure the store & state of the app are correct
this.refreshPage();
}
// Enable the button thanks to our jQuery disabling it
$(this.$refs.submitBtn).enable();
// Reset the selectors current page
Store.state.currentPage = '';
Store.state.reload = true;
} else if (this.currentPage === 'new') {
gl.utils.visitUrl(`${Store.rootPath}/${data.id}`);
}
})
.catch(() => {
// https://gitlab.com/gitlab-org/gitlab-ce/issues/30821
});
},
cancel() {
Store.state.currentPage = '';
},
selectMilestone(milestone) {
this.milestoneDropdownOpen = false;
this.board.milestone_id = milestone.id;
this.board.milestone = {
title: milestone.title,
};
},
},
});
})();
......@@ -51,10 +51,6 @@ export default {
project_id: this.selectedProject.id,
});
if (Store.state.currentBoard) {
issue.milestone_id = Store.state.currentBoard.milestone_id;
}
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
......
import Vue from 'vue';
import './board_new_form';
import BoardForm from './board_form.vue';
(() => {
window.gl = window.gl || {};
......@@ -11,7 +11,7 @@ import './board_new_form';
gl.issueBoards.BoardsSelector = Vue.extend({
components: {
'board-selector-form': gl.issueBoards.BoardSelectorForm,
BoardForm,
},
props: {
currentBoard: {
......@@ -60,19 +60,6 @@ import './board_new_form';
showDelete() {
return this.boards.length > 1;
},
title() {
if (this.currentPage === 'edit') {
return 'Edit board name';
} else if (this.currentPage === 'milestone') {
return 'Edit board milestone';
} else if (this.currentPage === 'new') {
return 'Create new board';
} else if (this.currentPage === 'delete') {
return 'Delete board';
}
return 'Go to a board';
},
},
methods: {
showPage(page) {
......
<script>
/* global ListLabel */
import LabelsSelect from '~/labels_select';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
props: {
board: {
type: Object,
required: true,
},
labelsPath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
components: {
loadingIcon,
},
computed: {
labelIds() {
return this.board.labels.map(label => label.id);
},
isEmpty() {
return this.board.labels.length === 0;
},
},
mounted() {
this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
handleClick: this.handleClick,
});
},
methods: {
labelStyle(label) {
return {
color: label.textColor,
backgroundColor: label.color,
};
},
handleClick(label) {
if (label.isAny) {
this.board.labels = [];
} else if (!this.board.labels.find(l => l.id === label.id)) {
this.board.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: label.text_color,
}));
} else {
let labels = this.board.labels;
labels = labels.filter(selected => selected.id !== label.id);
this.board.labels = labels;
}
},
},
};
</script>
<template>
<div class="block labels">
<div class="title append-bottom-10">
Labels
<button
v-if="canEdit"
type="button"
class="edit-link btn btn-blank pull-right"
>
Edit
</button>
</div>
<div class="value issuable-show-labels">
<span
v-if="isEmpty"
class="text-secondary"
>
Any Label
</span>
<a
v-else
href="#"
v-for="label in board.labels"
:key="label.id"
>
<span
class="label color-label"
:style="labelStyle(label)"
>
{{ label.title }}
</span>
</a>
</div>
<div
class="selectbox"
style="display: none"
>
<input
type="hidden"
name="label_id[]"
v-for="labelId in labelIds"
:key="labelId"
:value="labelId"
>
<div class="dropdown">
<button
ref="dropdownButton"
:data-labels="labelsPath"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-extra-options js-board-config-modal"
data-field-name="label_id[]"
:data-show-any="true"
data-toggle="dropdown"
type="button"
>
<span class="dropdown-toggle-text">
Label
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down"
data-hidden="true"
/>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable">
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
placeholder="Search"
type="search"
>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
data-hidden="true"
/>
<i
aria-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
role="button"
/>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</div>
</template>
/* global BoardService */
import extraMilestones from '../mixins/extra_milestones';
export default {
props: {
board: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
selectMilestone: {
type: Function,
required: true,
},
},
data() {
return {
loading: false,
milestones: [],
extraMilestones,
};
},
mounted() {
BoardService.loadMilestones.call(this);
},
template: `
<div>
<div class="text-center">
<i
v-if="loading"
class="fa fa-spinner fa-spin"></i>
</div>
<ul
class="board-milestone-list"
v-if="!loading">
<li v-for="milestone in extraMilestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
<li class="divider"></li>
<li v-for="milestone in milestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
</ul>
</div>
`,
};
<script>
/* global BoardService, MilestoneSelect */
import '~/milestone_select';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
const ANY_MILESTONE = 'Any Milestone';
const NO_MILESTONE = 'No Milestone';
export default {
props: {
board: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
components: {
loadingIcon,
},
computed: {
milestoneTitle() {
if (this.noMilestone) return NO_MILESTONE;
return this.board.milestone ? this.board.milestone.title : ANY_MILESTONE;
},
noMilestone() {
return this.milestoneId === 0;
},
milestoneId() {
return this.board.milestone_id;
},
milestoneTitleClass() {
return this.milestoneTitle === ANY_MILESTONE ? 'text-secondary' : 'bold';
},
selected() {
if (this.noMilestone) return NO_MILESTONE;
return this.board.milestone ? this.board.milestone.name : '';
},
},
methods: {
selectMilestone(milestone) {
let id = milestone.id;
// swap the IDs of 'Any' and 'No' milestone to what backend requires
if (milestone.title === ANY_MILESTONE) {
id = -1;
} else if (milestone.title === NO_MILESTONE) {
id = 0;
}
this.board.milestone_id = id;
this.board.milestone = {
...milestone,
id,
};
},
},
mounted() {
this.milestoneDropdown = new MilestoneSelect(null, this.$refs.dropdownButton, {
handleClick: this.selectMilestone,
});
},
};
</script>
<template>
<div class="block milestone">
<div class="title append-bottom-10">
Milestone
<button
v-if="canEdit"
type="button"
class="edit-link btn btn-blank pull-right"
>
Edit
</button>
</div>
<div
class="value"
:class="milestoneTitleClass"
>
{{ milestoneTitle }}
</div>
<div
class="selectbox"
style="display: none;"
>
<input
:value="milestoneId"
name="milestone_id"
type="hidden"
>
<div class="dropdown">
<button
ref="dropdownButton"
:data-selected="selected"
class="dropdown-menu-toggle wide"
:data-milestones="milestonePath"
:data-show-no="true"
:data-show-any="true"
:data-show-started="true"
:data-show-upcoming="true"
data-toggle="dropdown"
:data-use-id="true"
type="button"
>
Milestone
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-chevron-down"
/>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable">
<div
class="dropdown-input"
>
<input
type="search"
class="dropdown-input-field"
placeholder="Search milestones"
autocomplete="off"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-search dropdown-input-search"
/>
<i
role="button"
aria-hidden="true"
data-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
/>
</div>
<div class="dropdown-content" />
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</div>
</template>
......@@ -30,11 +30,16 @@ gl.issueBoards.ModalFooter = Vue.extend({
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.id);
const currentBoard = this.state.currentBoard;
const boardLabelIds = currentBoard.labels.map(label => label.id);
const assigneeIds = currentBoard.assignee && [currentBoard.assignee.id];
// Post the data to the backend
gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id],
milestone_id: this.state.currentBoard.milestone_id,
add_label_ids: [list.label.id, ...boardLabelIds],
milestone_id: currentBoard.milestone_id,
assignee_ids: assigneeIds,
weight: currentBoard.weight,
}).catch(() => {
new Flash('Failed to update issues, please try again.', 'alert');
......
......@@ -30,24 +30,43 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
},
methods: {
removeIssue() {
const board = Store.state.currentBoard;
const issue = this.issue;
const lists = issue.getLists();
const boardLabelIds = board.labels.map(label => label.id);
const listLabelIds = lists.map(list => list.label.id);
let labelIds = this.issue.labels
let labelIds = issue.labels
.map(label => label.id)
.filter(id => !listLabelIds.includes(id));
.filter(id => !listLabelIds.includes(id))
.filter(id => !boardLabelIds.includes(id));
if (labelIds.length === 0) {
labelIds = [''];
}
let assigneeIds = issue.assignees
.map(assignee => assignee.id)
.filter(id => id !== board.assignee.id);
if (assigneeIds.length === 0) {
// for backend to explicitly set No Assignee
assigneeIds = ['0'];
}
const data = {
issue: {
label_ids: labelIds,
assignee_ids: assigneeIds,
},
};
if (Store.state.currentBoard.milestone_id) {
if (board.milestone_id) {
data.issue.milestone_id = -1;
}
if (board.weight) {
data.issue.weight = null;
}
// Post the remove data
Vue.http.patch(this.updateUrl, data).catch(() => {
new Flash('Failed to remove issue from board, please try again.', 'alert');
......
<script>
/* global BoardService, WeightSelect */
import '~/weight_select';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
const ANY_WEIGHT = 'Any Weight';
const NO_WEIGHT = 'No Weight';
export default {
props: {
board: {
type: Object,
required: true,
},
value: {
type: [Number, String],
required: false,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
weights: {
type: Array,
required: true,
},
},
data() {
return {
fieldName: 'weight',
};
},
components: {
loadingIcon,
},
computed: {
valueClass() {
if (this.valueText === ANY_WEIGHT) {
return 'text-secondary';
}
return 'bold';
},
valueText() {
if (this.value > 0) return this.value;
if (this.value === 0) return NO_WEIGHT;
return ANY_WEIGHT;
},
},
methods: {
selectWeight(weight) {
this.board.weight = this.weightInt(weight);
},
weightInt(weight) {
if (weight > 0) {
return weight;
}
if (weight === NO_WEIGHT) {
return 0;
}
return -1;
},
},
mounted() {
this.weightDropdown = new WeightSelect(this.$refs.dropdownButton, {
handleClick: this.selectWeight,
selected: this.value,
fieldName: this.fieldName,
});
},
};
</script>
<template>
<div class="block weight">
<div class="title append-bottom-10">
Weight
<button
v-if="canEdit"
type="button"
class="edit-link btn btn-blank pull-right"
>
Edit
</button>
</div>
<div
class="value"
:class="valueClass"
>
{{ valueText }}
</div>
<div
class="selectbox"
style="display: none;"
>
<input
type="hidden"
:name="this.fieldName"
/>
<div class="dropdown ">
<button
ref="dropdownButton"
class="dropdown-menu-toggle js-weight-select wide"
type="button"
data-default-label="Weight"
data-toggle="dropdown"
>
<span class="dropdown-toggle-text is-default">
Weight
</span>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-chevron-down"
/>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-weight">
<div class="dropdown-content ">
<ul>
<li
v-for="weight in weights"
:key="weight"
>
<a
:class="{'is-active': weight == valueText}"
:data-id="weight"
href="#"
>
{{ weight }}
</a>
</li>
</ul>
</div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</div>
</template>
......@@ -11,7 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
this.cantEdit = cantEdit;
this.cantEdit = cantEdit.filter(i => typeof i === 'string');
this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
}
updateObject(path) {
......@@ -42,7 +43,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
canEdit(tokenName) {
return this.cantEdit.indexOf(tokenName) === -1;
canEdit(tokenName, tokenValue) {
if (this.cantEdit.includes(tokenName)) return false;
return this.cantEditWithValue.findIndex(token => token.name === tokenName &&
token.value === tokenValue) === -1;
}
}
......@@ -20,6 +20,7 @@ class ListIssue {
this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
this.weight = obj.weight;
if (obj.project) {
this.project = new IssueProject(obj.project);
......
......@@ -114,6 +114,8 @@ class List {
issue.iid = data.iid;
issue.milestone = data.milestone;
issue.project = data.project;
issue.assignees = data.assignees;
issue.labels = data.labels;
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
......
......@@ -30,10 +30,28 @@ class BoardService {
}
createBoard (board) {
board.label_ids = (board.labels || []).map(b => b.id);
if (board.label_ids.length === 0) {
board.label_ids = [''];
}
if (board.assignee) {
board.assignee_id = board.assignee.id;
}
if (board.milestone) {
board.milestone_id = board.milestone.id;
}
if (board.id) {
return this.boards.update({ id: board.id }, board);
return this.boards.update({ id: board.id }, { board });
}
return this.boards.save({}, board);
return this.boards.save({}, { board });
}
deleteBoard ({ id }) {
return this.boards.delete({ id });
}
all () {
......@@ -99,17 +117,6 @@ class BoardService {
return this.issues.bulkUpdate(data);
}
static loadMilestones(path) {
this.loading = true;
return this.$http.get(this.milestonePath)
.then(resp => resp.json())
.then((data) => {
this.milestones = data;
this.loading = false;
});
}
}
window.BoardService = BoardService;
......@@ -13,9 +13,15 @@ gl.issueBoards.BoardsStore = {
filter: {
path: '',
},
state: {},
state: {
currentBoard: {
labels: [],
},
currentPage: '',
reload: false,
},
detail: {
issue: {}
issue: {},
},
moving: {
issue: {},
......@@ -24,13 +30,21 @@ gl.issueBoards.BoardsStore = {
create () {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
this.detail = { issue: {} };
this.detail = {
issue: {},
};
},
createNewListDropdownData() {
this.state.currentBoard = {};
this.state.currentBoard = {
labels: [],
};
this.state.currentPage = '';
this.state.reload = false;
},
showPage(page) {
this.state.reload = false;
this.state.currentPage = page;
},
addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
this.state.lists.push(list);
......
......@@ -147,6 +147,16 @@ class DropdownUtils {
return dataValue !== null;
}
static getVisualTokenValues(visualToken) {
const tokenName = visualToken && visualToken.querySelector('.name').textContent.trim();
let tokenValue = visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim();
if (tokenName === 'label' && tokenValue) {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
return { tokenName, tokenValue };
}
// Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container;
......
......@@ -198,8 +198,8 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(lastVisualToken);
const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
......@@ -349,8 +349,8 @@ class FilteredSearchManager {
let canClearToken = t.classList.contains('js-visual-token');
if (canClearToken) {
const tokenKey = t.querySelector('.name').textContent.trim();
canClearToken = this.canEdit && this.canEdit(tokenKey);
const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(t);
canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
}
if (canClearToken) {
......@@ -482,7 +482,7 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey);
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
gl.FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
......
......@@ -38,21 +38,14 @@ class FilteredSearchVisualTokens {
}
static createVisualTokenElementHTML(canEdit = true) {
let removeTokenMarkup = '';
if (canEdit) {
removeTokenMarkup = `
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
`;
}
return `
<div class="selectable" role="button">
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
${removeTokenMarkup}
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
</div>
</div>
`;
......
......@@ -7,7 +7,7 @@ import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label';
export default class LabelsSelect {
constructor(els) {
constructor(els, options = {}) {
var _this, $els;
_this = this;
......@@ -57,6 +57,7 @@ export default class LabelsSelect {
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip();
......@@ -315,9 +316,9 @@ export default class LabelsSelect {
},
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(options) {
const { $el, e, isMarking } = options;
const label = options.selectedObj;
clicked: function(clickEvent) {
const { $el, e, isMarking } = clickEvent;
const label = clickEvent.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => {
......@@ -390,6 +391,10 @@ export default class LabelsSelect {
.then(fadeOutLoader)
.catch(fadeOutLoader);
}
else if (handleClick) {
e.preventDefault();
handleClick(label);
}
else {
if ($dropdown.hasClass('js-multiselect')) {
......
......@@ -5,7 +5,7 @@ import _ from 'underscore';
(function() {
this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject, els) {
function MilestoneSelect(currentProject, els, options = {}) {
var _this, $els;
if (currentProject != null) {
_this = this;
......@@ -141,7 +141,7 @@ import _ from 'underscore';
},
opened: function(e) {
const $el = $(e.currentTarget);
if ($dropdown.hasClass('js-issue-board-sidebar')) {
if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
......@@ -150,7 +150,8 @@ import _ from 'underscore';
vue: $dropdown.hasClass('js-issue-board-sidebar'),
hideRow: function(milestone) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) {
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone &&
!options.handleClick) {
return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
......@@ -158,18 +159,26 @@ import _ from 'underscore';
},
isSelectable: function() {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) {
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id &&
!options.handleClick) {
return false;
}
return true;
},
clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
clicked: function(clickEvent) {
const { $el, e } = clickEvent;
let selected = clickEvent.selectedObj;
var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
if (!selected) return;
if (options.handleClick) {
e.preventDefault();
options.handleClick(selected);
return;
}
page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
......
......@@ -6,7 +6,7 @@ import _ from 'underscore';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els) {
function UsersSelect(currentUser, els, options = {}) {
var $els;
this.users = this.users.bind(this);
this.user = this.user.bind(this);
......@@ -20,6 +20,8 @@ function UsersSelect(currentUser, els) {
}
}
const { handleClick } = options;
$els = $(els);
if (!els) {
......@@ -442,6 +444,9 @@ function UsersSelect(currentUser, els) {
}
if ($el.closest('.add-issues-modal').length) {
gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
} else if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
......
......@@ -5,17 +5,27 @@ export default {
props: {
title: {
type: String,
required: true,
required: false,
},
text: {
type: String,
required: false,
},
hideFooter: {
type: Boolean,
required: false,
default: false,
},
kind: {
type: String,
required: false,
default: 'primary',
},
modalDialogClass: {
type: String,
required: false,
default: '',
},
closeKind: {
type: String,
required: false,
......@@ -30,6 +40,11 @@ export default {
type: String,
required: true,
},
submitDisabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
......@@ -57,43 +72,58 @@ export default {
</script>
<template>
<div
class="modal popup-dialog"
role="dialog"
tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button"
class="close"
@click="close"
aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">{{this.title}}</h4>
</div>
<div class="modal-body">
<slot name="body" :text="text">
<p>{{text}}</p>
</slot>
</div>
<div class="modal-footer">
<button
type="button"
class="btn"
:class="btnCancelKindClass"
@click="close">
{{ closeButtonLabel }}
</button>
<button
type="button"
class="btn"
:class="btnKindClass"
@click="emitSubmit(true)">
{{ primaryButtonLabel }}
</button>
<div class="modal-open">
<div
class="modal popup-dialog"
role="dialog"
tabindex="-1"
>
<div
:class="modalDialogClass"
class="modal-dialog"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<slot name="header">
<h4 class="modal-title pull-left">
{{this.title}}
</h4>
<button
type="button"
class="close pull-right"
@click="close"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</slot>
</div>
<div class="modal-body">
<slot name="body" :text="text">
<p>{{this.text}}</p>
</slot>
</div>
<div class="modal-footer" v-if="!hideFooter">
<button
type="button"
class="btn pull-left"
:class="btnCancelKindClass"
@click="close">
{{ closeButtonLabel }}
</button>
<button
type="button"
class="btn pull-right"
:disabled="submitDisabled"
:class="btnKindClass"
@click="emitSubmit(true)">
{{ primaryButtonLabel }}
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade in" />
</div>
</template>
......@@ -2,8 +2,10 @@
(function() {
this.WeightSelect = (function() {
function WeightSelect() {
$('.js-weight-select').each(function(i, dropdown) {
function WeightSelect(els, options = {}) {
const $els = $(els || '.js-weight-select');
$els.each(function(i, dropdown) {
var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, updateUrl, updateWeight;
$dropdown = $(dropdown);
updateUrl = $dropdown.data('issueUpdate');
......@@ -13,6 +15,13 @@
$value = $block.find('.value');
abilityName = $dropdown.data('ability-name');
$loading = $block.find('.block-loading').fadeOut();
const fieldName = options.fieldName || $dropdown.data("field-name");
const inputField = $dropdown.closest('.selectbox').find(`input[name='${fieldName}']`);
if (Object.keys(options).includes('selected')) {
inputField.val(options.selected);
}
updateWeight = function(selected) {
var data;
data = {};
......@@ -39,7 +48,7 @@
};
return $dropdown.glDropdown({
selectable: true,
fieldName: $dropdown.data("field-name"),
fieldName,
toggleLabel: function (selected, el) {
return $(el).data("id");
},
......@@ -54,16 +63,21 @@
return '';
}
},
clicked: function(options) {
const e = options.e;
let selected = options.selectedObj;
clicked: function(glDropdownEvt) {
const e = glDropdownEvt.e;
let selected = glDropdownEvt.selectedObj;
const inputField = $dropdown.closest('.selectbox').find(`input[name='${fieldName}']`);
if ($(dropdown).is(".js-filter-submit")) {
if (options.handleClick) {
e.preventDefault();
selected = inputField.val();
options.handleClick(selected);
} else if ($(dropdown).is(".js-filter-submit")) {
return $(dropdown).parents('form').submit();
} else if ($dropdown.is('.js-issuable-form-weight')) {
e.preventDefault();
} else {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
selected = inputField.val();
return updateWeight(selected);
}
}
......
......@@ -4,6 +4,9 @@
.cred { color: $common-red; }
.cgreen { color: $common-green; }
.cdark { color: $common-gray-dark; }
.text-secondary {
color: $gl-text-color-secondary;
}
.underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; }
......
......@@ -37,6 +37,7 @@
.dropdown-menu-nav {
@include set-visible;
display: block;
min-height: 40px;
@media (max-width: $screen-xs-max) {
width: 100%;
......
......@@ -42,3 +42,11 @@ body.modal-open {
width: 98%;
}
}
.modal.popup-dialog {
display: block;
}
.modal-body {
background-color: $modal-body-bg;
}
......@@ -164,3 +164,36 @@ $pre-border-color: $border-color;
$table-bg-accent: $gray-light;
$zindex-popover: 900;
//== Modals
//
//##
//** Padding applied to the modal body
$modal-inner-padding: $gl-padding;
//** Padding applied to the modal title
$modal-title-padding: $gl-padding;
//** Modal title line-height
// $modal-title-line-height: $line-height-base
//** Background color of modal content area
$modal-content-bg: $gray-light;
$modal-body-bg: $white-light;
//** Modal content border color
// $modal-content-border-color: rgba(0,0,0,.2)
//** Modal content border color **for IE8**
// $modal-content-fallback-border-color: #999
//** Modal backdrop background color
// $modal-backdrop-bg: #000
//** Modal backdrop opacity
// $modal-backdrop-opacity: .5
//** Modal header border color
// $modal-header-border-color: #e5e5e5
//** Modal footer border color
// $modal-footer-border-color: $modal-header-border-color
// $modal-lg: 900px
// $modal-md: 600px
// $modal-sm: 300px
......@@ -469,6 +469,10 @@
white-space: normal;
}
.form-section-title {
font-size: 16px;
}
.board-delete-btns {
padding-top: 12px;
border-top: 1px solid $border-color;
......@@ -727,3 +731,16 @@
padding-right: 12px;
color: $gl-gray-dark;
}
.board-config-modal {
width: 440px;
.block {
padding: $gl-padding 0;
// add a border between all items, but not at the start or end
+ .block {
border-top: solid 1px $border-color;
}
}
}
......@@ -127,6 +127,7 @@ module IssuableActions
:milestone_id,
:state_event,
:subscription_event,
:weight,
label_ids: [],
add_label_ids: [],
remove_label_ids: []
......
......@@ -22,17 +22,6 @@ module BoardsHelper
project_issues_path(@project)
end
def current_board_json
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id],
include: {
milestone: { only: [:title] }
}
)
end
def board_base_url
project_boards_path(@project)
end
......
class BoardAssignee < ActiveRecord::Base
belongs_to :board
belongs_to :assignee, class_name: 'User'
validates :board, presence: true
validates :assignee, presence: true
end
class BoardLabel < ActiveRecord::Base
belongs_to :board
belongs_to :label
validates :board, presence: true
validates :label, presence: true
end
......@@ -30,6 +30,7 @@ class License < ActiveRecord::Base
related_issues
repository_mirrors
repository_size_limit
scoped_issue_board
].freeze
EEP_FEATURES = EES_FEATURES + %i[
......@@ -70,7 +71,6 @@ class License < ActiveRecord::Base
group_webhooks
issuable_default_templates
issue_board_focus_mode
issue_board_milestone
issue_weights
jenkins_integration
merge_request_approvers
......
......@@ -227,10 +227,6 @@ class Namespace < ActiveRecord::Base
feature_available?(:multiple_issue_boards)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone)
end
private
def refresh_access_of_projects_invited_groups
......
......@@ -1595,10 +1595,6 @@ class Project < ActiveRecord::Base
feature_available?(:multiple_issue_boards, user)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone, user)
end
def full_path_was
File.join(namespace.full_path, previous_changes['path'].first)
end
......
......@@ -6,5 +6,10 @@ module Boards
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
def set_assignee
assignee = User.find_by(id: params.delete(:assignee_id))
params.merge!(assignee: assignee)
end
end
end
......@@ -13,6 +13,7 @@ module Boards
end
def create_board!
set_assignee
board = parent.boards.create(params)
if board.persisted?
......
......@@ -10,11 +10,25 @@ module Boards
end
def execute
create_issue(params.merge(label_ids: [list.label_id]))
create_issue(creation_params)
end
private
def creation_params
params.merge(label_ids: [list.label_id, *board.label_ids],
weight: board.weight,
milestone_id: board.milestone_id,
assignee_ids: assignee_ids)
end
# This can be safely removed when the board
# receive multiple assignee support.
# See: https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
def assignee_ids
@assigne_ids ||= Array(board.assignee&.id)
end
def board
@board ||= parent.boards.find(params.delete(:board_id))
end
......
module Boards
class UpdateService < Boards::BaseService
def execute(board)
params.delete(:milestone_id) unless parent.feature_available?(:issue_board_milestone)
unless parent.feature_available?(:scoped_issue_board)
params.delete(:milestone_id)
params.delete(:assignee_id)
params.delete(:label_ids)
params.delete(:weight)
end
set_assignee
board.update(params)
end
......
......@@ -5,63 +5,42 @@
%boards-selector{ "inline-template" => true,
":current-board" => current_board_json,
"milestone-path" => milestones_filter_path(milestone_filter_opts) }
.dropdown
%button.dropdown-menu-toggle{ "v-on:click" => "loadBoards",
data: { toggle: "dropdown" } }
{{ board.name }}
= icon("chevron-down")
.dropdown-menu{ ":class" => "{ 'is-loading': loading }" }
.dropdown-title
%button.dropdown-title-button.dropdown-menu-back{ type: "button",
aria: { label: "Go back" },
"v-on:click.stop.prevent" => "showPage('')",
"v-if" => "currentPage !== ''" }
= icon("arrow-left")
{{ title }}
%button.dropdown-title-button.dropdown-menu-close{ type: "button",
aria: { label: "Close" } }
= icon("times", class: "dropdown-menu-close-icon")
.dropdown-content{ "v-if" => "currentPage === ''" }
%ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{board_base_url}/' + board.id" }
{{ board.name }}
- if !multiple_boards_available? && current_board_parent.boards.size > 1
%li.small
Some of your boards are hidden, activate a license to see them again.
.dropdown-loading{ "v-if" => "loading" }
= icon("spin spinner")
- if can?(current_user, :admin_board, parent)
%board-selector-form{ "inline-template" => true,
":milestone-path" => "milestonePath",
"v-if" => "currentPage === 'new' || currentPage === 'edit' || currentPage === 'milestone'" }
= render "shared/boards/components/form"
.dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p
Are you sure you want to delete this board?
.board-delete-btns.clearfix
= link_to current_board_path(board),
class: "btn btn-danger pull-left",
method: :delete do
Delete
%button.btn.btn-default.pull-right{ type: "button",
"v-on:click.stop.prevent" => "showPage('')" }
Cancel
- if can?(current_user, :admin_board, parent)
.dropdown-footer{ "v-if" => "currentPage === ''" }
%ul.dropdown-footer-list
- if parent.feature_available?(:multiple_issue_boards)
%span.boards-selector-wrapper
.dropdown
%button.dropdown-menu-toggle{ "v-on:click" => "loadBoards",
data: { toggle: "dropdown" } }
{{ board.name }}
= icon("chevron-down")
.dropdown-menu{ ":class" => "{ 'is-loading': loading }" }
.dropdown-content
%ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{board_base_url}/' + board.id" }
{{ board.name }}
- if !multiple_boards_available? && current_board_parent.boards.size > 1
%li
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('new')" }
Create new board
%li
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('edit')" }
Edit board name
- if parent.issue_board_milestone_available?(current_user)
%li
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('milestone')" }
Edit board milestone
%li{ "v-if" => "showDelete" }
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('delete')" }
%span.text-danger
Delete board
.small.unclickable
Some of your boards are hidden, activate a license to see them again.
.dropdown-loading{ "v-if" => "loading" }
= icon("spin spinner")
- if can?(current_user, :admin_board, parent)
.dropdown-footer
%ul.dropdown-footer-list
- if parent.feature_available?(:multiple_issue_boards)
%li
%a{ "href" => "#", "v-on:click.prevent" => "showPage('new')" }
Create new board
%li{ "v-if" => "showDelete" }
%a{ "href" => "#", "v-on:click.prevent" => "showPage('delete')" }
%span.text-danger
Delete board
%board-form{ ":milestone-path" => "milestonePath",
"labels-path" => labels_filter_path(true),
":project-id" => "Number(#{@project&.id})",
":group-id" => "Number(#{@group&.id})",
":can-admin-board" => can?(current_user, :admin_board, parent),
":scoped-issue-board-feature-enabled" => parent.feature_available?(:scoped_issue_board),
"weights" => [Issue::WEIGHT_ANY] + Issue.weight_options,
"v-if" => "currentPage" }
.board-selector-page-two
%form{ "v-on:submit.prevent" => "submit" }
.dropdown-content
%input{ type: "hidden",
id: "board-milestone",
"v-model.number" => "board.milestone_id" }
%div{ "v-if" => "currentPage !== 'milestone'" }
%label.label-light{ for: "board-new-name" }
Board name
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
- if current_board_parent.issue_board_milestone_available?(current_user)
.dropdown.board-inner-milestone-dropdown{ ":class" => "{ open: milestoneDropdownOpen }",
"v-if" => "currentPage === 'new'" }
%label.label-light{ for: "board-milestone" }
Board milestone
%button.dropdown-menu-toggle.wide{ type: "button",
"v-on:click.stop.prevent" => "loadMilestones($event)" }
{{ milestoneToggleText }}
= icon("chevron-down")
.dropdown-menu.dropdown-menu-selectable{ "v-if" => "milestoneDropdownOpen",
ref: "milestoneDropdown" }
.dropdown-content
%ul
%li{ "v-for" => "milestone in extraMilestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"v-on:click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
%li.divider
%li{ "v-for" => "milestone in milestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"v-on:click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
= dropdown_loading
%span
Only show issues scheduled for the selected milestone
%board-milestone-select{ "v-if" => "currentPage == 'milestone'",
":milestone-path" => "milestonePath",
":select-milestone" => "selectMilestone",
":board" => "board" }
.dropdown-footer
%button.btn.btn-primary.pull-left{ type: "submit",
":disabled" => "submitDisabled",
"ref" => "'submit-btn'" }
{{ buttonText }}
%button.btn.btn-default.pull-right{ type: "button",
"v-on:click.stop.prevent" => "cancel" }
Cancel
......@@ -123,7 +123,10 @@
= icon('times')
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, board.parent)
- user_can_admin_list = can?(current_user, :admin_list, board.parent)
.js-board-config{ data: { can_admin_list: user_can_admin_list.to_s } }
- if user_can_admin_list
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
......
---
title: Allow persisting board configuration in order to automatically filter issues
merge_request:
author:
type: added
class AddWeightToBoards < ActiveRecord::Migration
DOWNTIME = false
disable_ddl_transaction!
def change
add_column :boards, :weight, :integer, index: true
end
end
class CreateBoardLabels < ActiveRecord::Migration
DOWNTIME = false
disable_ddl_transaction!
def change
create_table :board_labels do |t|
t.references :board, foreign_key: { on_delete: :cascade }, null: false
t.references :label, foreign_key: { on_delete: :cascade }, null: false
t.index [:board_id, :label_id], unique: true
end
end
end
class CreateBoardAssignees < ActiveRecord::Migration
DOWNTIME = false
disable_ddl_transaction!
def change
create_table :board_assignees do |t|
t.references :board, foreign_key: { on_delete: :cascade }, null: false
t.integer :assignee_id, null: false
t.foreign_key :users, column: :assignee_id, on_delete: :cascade
t.index [:board_id, :assignee_id], unique: true
end
end
end
......@@ -221,6 +221,20 @@ ActiveRecord::Schema.define(version: 20171026082505) do
add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
create_table "board_assignees", force: :cascade do |t|
t.integer "board_id", null: false
t.integer "assignee_id", null: false
end
add_index "board_assignees", ["board_id", "assignee_id"], name: "index_board_assignees_on_board_id_and_assignee_id", unique: true, using: :btree
create_table "board_labels", force: :cascade do |t|
t.integer "board_id", null: false
t.integer "label_id", null: false
end
add_index "board_labels", ["board_id", "label_id"], name: "index_board_labels_on_board_id_and_label_id", unique: true, using: :btree
create_table "boards", force: :cascade do |t|
t.integer "project_id"
t.datetime "created_at", null: false
......@@ -228,6 +242,7 @@ ActiveRecord::Schema.define(version: 20171026082505) do
t.string "name", default: "Development", null: false
t.integer "milestone_id"
t.integer "group_id"
t.integer "weight"
end
add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
......@@ -2196,6 +2211,10 @@ ActiveRecord::Schema.define(version: 20171026082505) do
add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "board_assignees", "boards", on_delete: :cascade
add_foreign_key "board_assignees", "users", column: "assignee_id", on_delete: :cascade
add_foreign_key "board_labels", "boards", on_delete: :cascade
add_foreign_key "board_labels", "labels", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", name: "fk_1e9a074a35", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
......
......@@ -11,6 +11,74 @@ class BoardsStoreEE {
this.store.removePromotionState = () => {
this.removePromotion();
};
const baseCreate = this.store.create.bind(this.store);
this.store.create = () => {
baseCreate();
if (this.$boardApp) {
this.store.boardConfig = {
milestoneId: parseInt(this.$boardApp.dataset.boardMilestoneId, 10),
milestoneTitle: this.$boardApp.dataset.boardMilestoneTitle,
assigneeUsername: this.$boardApp.dataset.boardAssigneeUsername,
labels: JSON.parse(this.$boardApp.dataset.labels || []),
weight: parseInt(this.$boardApp.dataset.boardWeight, 10),
};
this.store.cantEdit = [];
this.initBoardFilters();
}
};
}
initBoardFilters() {
const updateFilterPath = (key, value) => {
if (!value) return;
const querystring = `${key}=${value}`;
this.store.filter.path = [querystring].concat(
this.store.filter.path.split('&').filter(param => param.match(new RegExp(`^${key}=(.*)$`, 'g')) === null),
).join('&');
};
let milestoneTitle = this.store.boardConfig.milestoneTitle;
if (this.store.boardConfig.milestoneId === 0) {
milestoneTitle = 'No+Milestone';
}
if (milestoneTitle) {
updateFilterPath('milestone_title', encodeURIComponent(milestoneTitle));
this.store.cantEdit.push('milestone');
}
let weight = this.store.boardConfig.weight;
if (weight !== -1) {
if (weight === 0) {
weight = 'No+Weight';
}
updateFilterPath('weight', weight);
this.store.cantEdit.push('weight');
}
updateFilterPath('assignee_username', this.store.boardConfig.assigneeUsername);
if (this.store.boardConfig.assigneeUsername) {
this.store.cantEdit.push('assignee');
}
const filterPath = this.store.filter.path.split('&');
this.store.boardConfig.labels.forEach((label) => {
const labelTitle = encodeURIComponent(label.title);
const param = `label_name[]=${labelTitle}`;
const labelIndex = filterPath.indexOf(param);
if (labelIndex === -1) {
filterPath.push(param);
}
this.store.cantEdit.push({
name: 'label',
value: label.title,
});
});
this.store.filter.path = filterPath.join('&');
this.store.updateFiltersUrl(true);
}
shouldAddPromotionState() {
......
......@@ -15,7 +15,8 @@ module EE
respond_to do |format|
format.json do
if board.valid?
render json: serialize_as_json(board)
extra_json = { board_path: board_path(board) }
render json: serialize_as_json(board).merge(extra_json)
else
render json: board.errors, status: :unprocessable_entity
end
......@@ -31,7 +32,8 @@ module EE
respond_to do |format|
format.json do
if @board.valid?
render json: serialize_as_json(@board)
extra_json = { board_path: board_path(@board) }
render json: serialize_as_json(@board).merge(extra_json)
else
render json: @board.errors, status: :unprocessable_entity
end
......@@ -44,6 +46,7 @@ module EE
service.execute(@board)
respond_to do |format|
format.json { head :ok }
format.html { redirect_to boards_path, status: 302 }
end
end
......@@ -55,7 +58,7 @@ module EE
end
def board_params
params.require(:board).permit(:name, :milestone_id)
params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
end
def find_board
......@@ -74,6 +77,14 @@ module EE
end
end
def board_path(board)
if @group
group_board_path(parent, board)
else
project_board_path(parent, board)
end
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :name],
......
......@@ -5,11 +5,20 @@ module EE
end
def board_data
show_feature_promotion = (@project && show_promotions? &&
(!@project.feature_available?(:multiple_issue_boards) ||
!@project.feature_available?(:scoped_issue_board) ||
!@project.feature_available?(:issue_board_focus_mode))).to_s
data = {
board_milestone_title: board&.milestone&.title,
board_milestone_title: board.milestone&.name,
board_milestone_id: board.milestone_id,
board_assignee_username: board.assignee&.username,
label_ids: board.label_ids,
labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ),
board_weight: board.weight,
focus_mode_available: parent.feature_available?(:issue_board_focus_mode).to_s,
show_promotion: (@project && show_promotions? && (!@project.feature_available?(:multiple_issue_boards) || !@project.feature_available?(:issue_board_milestone) || !@project.feature_available?(:issue_board_focus_mode))).to_s
show_promotion: show_feature_promotion
}
super.merge(data)
......@@ -21,6 +30,19 @@ module EE
"#{group_path(@board.group)}/:project_path/issues"
end
def current_board_json
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id, :assignee_id, :weight, :label_ids],
include: {
milestone: { only: [:id, :title, :name] },
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { only: [:title, :color, :id] }
}
)
end
def board_base_url
if board.group_board?
group_boards_url(@group)
......
......@@ -3,8 +3,18 @@ module EE
extend ActiveSupport::Concern
prepended do
belongs_to :milestone
belongs_to :group
belongs_to :milestone
has_many :board_labels
# These can safely be changed to has_many when we support
# multiple assignees on the board configuration.
# https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
has_one :board_assignee
has_one :assignee, through: :board_assignee
has_many :labels, through: :board_labels
validates :name, presence: true
validates :group, presence: true, unless: :project
......@@ -14,16 +24,6 @@ module EE
!group
end
def milestone
return nil unless parent.feature_available?(:issue_board_milestone)
if milestone_id == ::Milestone::Upcoming.id
::Milestone::Upcoming
else
super
end
end
def parent
@parent ||= group || project
end
......@@ -32,6 +32,19 @@ module EE
group_id.present?
end
def milestone
return nil unless parent.feature_available?(:scoped_issue_board)
case milestone_id
when ::Milestone::Upcoming.id
::Milestone::Upcoming
when ::Milestone::Started.id
::Milestone::Started
else
super
end
end
def as_json(options = {})
milestone_attrs = options.fetch(:include, {})
.extract!(:milestone)
......
......@@ -12,9 +12,9 @@
- unless @project.feature_available?(:multiple_issue_boards)
%li
= link_to _('Multiple issue boards'), help_page_path('user/project/issue_board.html', anchor:'use-cases-for-multiple-issue-boards'), target: '_blank'
- unless @project.feature_available?(:issue_board_milestone)
- unless @project.feature_available?(:scoped_issue_board)
%li
= link_to _('Issue boards with milestones'), help_page_path('user/project/issue_board.html', anchor:'board-with-a-milestone'), target: '_blank'
= link_to _('Scoped issue boards'), help_page_path('user/project/issue_board.html', anchor:'board-with-a-milestone'), target: '_blank'
- unless @project.feature_available?(:issue_board_focus_mode)
%li
= link_to _('Issue board focus mode'), help_page_path('user/project/issue_board.html', anchor:'focus-mode'), target: '_blank'
......
......@@ -794,11 +794,21 @@ module API
expose :id
expose :name
expose :project, using: Entities::BasicProjectDetails
expose :milestone,
if: -> (board, _) { board.project.feature_available?(:issue_board_milestone) }
# EE-specific
# Default filtering configuration
expose :milestone, using: Entities::Milestone, if: -> (board, _) { scoped_issue_available?(board) }
expose :assignee, using: Entities::UserBasic, if: -> (board, _) { scoped_issue_available?(board) }
expose :labels, using: Entities::LabelBasic, if: -> (board, _) { scoped_issue_available?(board) }
expose :weight, if: -> (board, _) { scoped_issue_available?(board) }
expose :lists, using: Entities::List do |board|
board.lists.destroyable
end
def scoped_issue_available?(board)
board.parent.feature_available?(:scoped_issue_board)
end
end
class Compare < Grape::Entity
......
......@@ -36,17 +36,39 @@ describe Projects::BoardsController do
end
context 'with valid params' do
let(:user) { create(:user) }
let(:milestone) { create(:milestone) }
let(:label) { create(:label) }
let(:create_params) do
{ name: 'Backend',
weight: 1,
milestone_id: milestone.id,
assignee_id: user.id,
label_ids: [label.id] }
end
it 'returns a successful 200 response' do
create_board name: 'Backend'
create_board create_params
expect(response).to have_gitlab_http_status(200)
end
it 'returns the created board' do
create_board name: 'Backend'
create_board create_params
expect(response).to match_response_schema('board')
end
it 'valid board is created' do
create_board create_params
board = Board.first
expect(Board.count).to eq(1)
expect(board).to have_attributes(create_params.except(:assignee_id))
expect(board.assignee).to eq(user)
end
end
context 'with invalid params' do
......@@ -80,34 +102,52 @@ describe Projects::BoardsController do
expect(response).to have_gitlab_http_status(404)
end
def create_board(name:)
def create_board(board_params)
post :create, namespace_id: project.namespace.to_param,
project_id: project.to_param,
board: { name: name },
board: board_params,
format: :json
end
end
describe 'PATCH update' do
let(:board) { create(:board, project: project, name: 'Backend') }
let(:user) { create(:user) }
let(:milestone) { create(:milestone) }
let(:label) { create(:label) }
let(:update_params) do
{ name: 'Frontend',
weight: 1,
milestone_id: milestone.id,
assignee_id: user.id,
label_ids: [label.id] }
end
context 'with valid params' do
it 'returns a successful 200 response' do
update_board board: board, name: 'Frontend'
update_board board, update_params
expect(response).to have_gitlab_http_status(200)
end
it 'returns the updated board' do
update_board board: board, name: 'Frontend'
update_board board, update_params
expect(response).to match_response_schema('board')
end
it 'updates board with valid params' do
update_board board, update_params
expect(board.reload).to have_attributes(update_params.except(:assignee_id))
expect(board.assignee).to eq(user)
end
end
context 'with invalid params' do
it 'returns an unprocessable entity 422 response' do
update_board board: board, name: nil
update_board board, name: nil
expect(response).to have_gitlab_http_status(422)
end
......@@ -115,7 +155,7 @@ describe Projects::BoardsController do
context 'with invalid board id' do
it 'returns a not found 404 response' do
update_board board: 999, name: 'Frontend'
update_board 999, name: nil
expect(response).to have_gitlab_http_status(404)
end
......@@ -128,18 +168,18 @@ describe Projects::BoardsController do
end
it 'returns a not found 404 response' do
update_board board: board, name: 'Backend'
update_board board, update_params
expect(response.content_type).to eq 'application/json'
expect(response).to have_gitlab_http_status(404)
end
end
def update_board(board:, name:)
def update_board(board, update_params)
patch :update, namespace_id: project.namespace.to_param,
project_id: project.to_param,
id: board.to_param,
board: { name: name },
board: update_params,
format: :json
end
end
......
This diff is collapsed.
......@@ -131,9 +131,8 @@ describe 'Service Desk Issue Tracker', :js do
visit service_desk_project_issues_path(project)
end
it 'displays the support bot author token' do
author_token = find('.filtered-search-token .value')
expect(author_token).to have_content('Support Bot')
it 'adds hidden support bot author token' do
expect(page).to have_selector('.filtered-search-token .value', text: 'Support Bot', visible: false)
end
it 'support bot author token cannot be deleted' do
......
require 'spec_helper'
describe Board do
let(:board) { create(:board) }
it { is_expected.to include_module(EE::Board) }
context 'validations' do
context 'when group is present' do
subject { described_class.new(group: create(:group)) }
......@@ -18,11 +22,9 @@ describe Board do
end
describe 'milestone' do
subject(:board) { build(:board) }
context 'when the feature is available' do
before do
stub_licensed_features(issue_board_milestone: true)
stub_licensed_features(scoped_issue_board: true)
end
it 'returns Milestone::Upcoming for upcoming milestone id' do
......@@ -31,6 +33,12 @@ describe Board do
expect(board.milestone).to eq Milestone::Upcoming
end
it 'returns Milestone::Started for started milestone id' do
board.milestone_id = Milestone::Started.id
expect(board.milestone).to eq Milestone::Started
end
it 'returns milestone for valid milestone id' do
milestone = create(:milestone)
board.milestone_id = milestone.id
......@@ -46,7 +54,7 @@ describe Board do
end
it 'returns nil when the feature is not available' do
stub_licensed_features(issue_board_milestone: false)
stub_licensed_features(scoped_issue_board: false)
milestone = create(:milestone)
board.milestone_id = milestone.id
......
FactoryGirl.define do
factory :board_label do
association :board
association :label
end
end
require 'rails_helper'
describe 'Board with milestone', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let!(:milestone) { create(:milestone, project: project) }
let!(:issue) { create(:closed_issue, project: project) }
let!(:issue_milestone) { create(:closed_issue, project: project, milestone: milestone) }
before do
allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
project.team << [user, :master]
sign_in(user)
end
context 'with the feature enabled' do
before do
stub_licensed_features(issue_board_milestone: true)
end
context 'new board' do
before do
visit project_boards_path(project)
end
it 'creates board with milestone' do
create_board_with_milestone
expect(find('.tokens-container')).to have_content(milestone.title)
wait_for_requests
find('.card', match: :first)
expect(all('.board').last).to have_selector('.card', count: 1)
end
end
context 'update board' do
let!(:milestone_two) { create(:milestone, project: project) }
let!(:board) { create(:board, project: project, milestone: milestone) }
before do
visit project_boards_path(project)
end
it 'defaults milestone filter' do
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
wait_for_requests
click_link board.name
end
expect(find('.tokens-container')).to have_content(milestone.title)
find('.card', match: :first)
expect(all('.board').last).to have_selector('.card', count: 1)
end
it 'sets board to any milestone' do
update_board_milestone('Any Milestone')
expect(page).not_to have_css('.js-visual-token')
expect(find('.tokens-container')).not_to have_content(milestone.title)
find('.card', match: :first)
expect(page).to have_selector('.board', count: 3)
expect(all('.board').last).to have_selector('.card', count: 2)
end
it 'sets board to upcoming milestone' do
update_board_milestone('Upcoming')
expect(find('.tokens-container')).not_to have_content(milestone.title)
find('.board', match: :first)
expect(all('.board')[1]).to have_selector('.card', count: 0)
end
it 'does not allow milestone in filter to be editted' do
find('.filtered-search').native.send_keys(:backspace)
page.within('.tokens-container') do
expect(page).to have_selector('.value')
end
end
it 'does not render milestone in hint dropdown' do
find('.filtered-search').click
page.within('#js-dropdown-hint') do
expect(page).not_to have_button('Milestone')
end
end
end
context 'removing issue from board' do
let(:label) { create(:label, project: project) }
let!(:issue) { create(:labeled_issue, project: project, labels: [label], milestone: milestone) }
let!(:board) { create(:board, project: project, milestone: milestone) }
let!(:list) { create(:list, board: board, label: label, position: 0) }
before do
visit project_boards_path(project)
end
it 'removes issues milestone when removing from the board' do
wait_for_requests
first('.card .card-number').click
click_button('Remove from board')
wait_for_requests
expect(issue.reload.milestone).to be_nil
end
end
context 'new issues' do
let(:label) { create(:label, project: project) }
let!(:list1) { create(:list, board: board, label: label, position: 0) }
let!(:board) { create(:board, project: project, milestone: milestone) }
let!(:issue) { create(:issue, project: project) }
before do
visit project_boards_path(project)
end
it 'creates new issue with boards milestone' do
wait_for_requests
page.within(first('.board')) do
find('.btn-default').click
find('.form-control').set('testing new issue with milestone')
click_button('Submit issue')
wait_for_requests
click_link('testing new issue with milestone')
end
expect(page).to have_content(milestone.title)
end
it 'updates issue with milestone from add issues modal' do
wait_for_requests
click_button 'Add issues'
page.within('.add-issues-modal') do
card = find('.card', :first)
expect(page).to have_selector('.card', count: 1)
card.click
click_button 'Add 1 issue'
end
click_link(issue.title)
expect(page).to have_content(milestone.title)
end
end
end
context 'with the feature disabled' do
before do
stub_licensed_features(issue_board_milestone: false)
visit project_boards_path(project)
end
it "doesn't show the input when creating a board" do
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
click_link 'Create new board'
# To make sure the form is shown
expect(page).to have_selector('#board-new-name')
expect(page).not_to have_button('Milestone')
end
end
it "doesn't show the option to edit the milestone" do
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
# To make sure the dropdown is open
expect(page).to have_link('Edit board name')
expect(page).not_to have_link('Edit board milestone')
end
end
end
def create_board_with_milestone
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
click_link 'Create new board'
find('#board-new-name').set 'test'
find('button', text: 'Any Milestone').click
find('a', text: milestone.title).click
click_button 'Create'
end
end
def update_board_milestone(milestone_title)
page.within '#js-multiple-boards-switcher' do
find('.dropdown-menu-toggle').click
click_link 'Edit board milestone'
click_link milestone_title
click_button 'Save'
end
end
end
......@@ -51,40 +51,20 @@ describe 'Multiple Issue Boards', :js do
end
end
it 'creates new board' do
it 'creates new board without detailed configuration' do
click_button board.name
page.within('.dropdown-menu') do
click_link 'Create new board'
fill_in 'board-new-name', with: 'This is a new board'
click_button 'Create'
end
fill_in 'board-new-name', with: 'This is a new board'
click_button 'Create board'
wait_for_requests
expect(page).to have_button('This is a new board')
end
it 'edits board name' do
click_button board.name
page.within('.dropdown-menu') do
click_link 'Edit board name'
fill_in 'board-new-name', with: 'Testing'
click_button 'Save'
end
wait_for_requests
page.within('.dropdown-menu') do
expect(page).to have_content('Testing')
end
end
it 'deletes board' do
click_button board.name
......@@ -92,16 +72,12 @@ describe 'Multiple Issue Boards', :js do
page.within('.dropdown-menu') do
click_link 'Delete board'
page.within('.dropdown-title') do
expect(page).to have_content('Delete board')
end
click_link 'Delete'
end
click_button board2.name
expect(page).to have_content('Are you sure you want to delete this board?')
click_button 'Delete'
click_button board2.name
page.within('.dropdown-menu') do
expect(page).not_to have_content(board.name)
expect(page).to have_content(board2.name)
......@@ -166,7 +142,6 @@ describe 'Multiple Issue Boards', :js do
page.within('.dropdown-menu') do
expect(page).not_to have_content('Create new board')
expect(page).not_to have_content('Edit board name')
expect(page).not_to have_content('Delete board')
end
end
......@@ -188,10 +163,11 @@ describe 'Multiple Issue Boards', :js do
click_button board.name
page.within('.dropdown-menu') do
expect(page).to have_content('Edit board name')
expect(page).not_to have_content('Create new board')
expect(page).not_to have_content('Delete board')
end
expect(page).to have_content('Edit board')
end
it 'shows a mention that boards are hidden when multiple boards are created' do
......
......@@ -7,6 +7,7 @@
"properties" : {
"id": { "type": "integer" },
"name": { "type": "string" },
"board_path": { "type": ["string", "null"] },
"milestone": {
"type": ["object", "null"],
"required": [
......
......@@ -3,13 +3,17 @@
"required" : [
"id",
"name",
"weight",
"project",
"milestone",
"assignee",
"labels",
"lists"
],
"properties" : {
"id": { "type": "integer" },
"name": { "type": "string" },
"weight": { "type": ["string", "null"] },
"project": {
"type": "object",
"required": [
......@@ -50,6 +54,13 @@
},
"additionalProperties": false
},
"assignee": {
"$ref": "user/basic.json"
},
"labels": {
"type": "array",
"items": { "$ref": "label/basic.json" }
},
"milestone": {
"type": ["object", "null"],
"required": [
......
{
"type": "object",
"required": ["id", "name", "description"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"description": { "type": ["string", "null"] }
}
}
/* global boardsMockInterceptor */
/* global boardObj */
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import AssigneeSelect from '~/boards/components/assignee_select.vue';
import '~/boards/services/board_service';
import '~/boards/stores/boards_store';
import IssuableContext from '~/issuable_context';
let vm;
function selectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
function activeDropdownItem(index) {
const items = document.querySelectorAll('.is-active');
if (!items[index]) return '';
return items[index].innerText.trim();
}
const assignee = {
id: 1,
name: 'first assignee',
};
const assignee2 = {
id: 2,
name: 'second assignee',
};
describe('Assignee select component', () => {
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
// eslint-disable-next-line no-new
new IssuableContext();
const Component = Vue.extend(AssigneeSelect);
vm = new Component({
propsData: {
board: boardObj,
assigneePath: '/test/issue-boards/assignees.json',
canEdit: true,
label: 'Assignee',
selected: {},
fieldName: 'assignee_id',
anyUserText: 'Any assignee',
},
}).$mount('.test-container');
setTimeout(done);
});
describe('canEdit', () => {
it('hides Edit button', (done) => {
vm.canEdit = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
done();
});
});
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
});
});
describe('selected value', () => {
it('defaults to Any Assignee', () => {
expect(selectedText()).toContain('Any assignee');
});
it('shows selected assignee', (done) => {
vm.selected = assignee;
Vue.nextTick(() => {
expect(selectedText()).toContain('first assignee');
done();
});
});
describe('clicking dropdown items', () => {
beforeEach(() => {
const deferred = new jQuery.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.resolve([
assignee,
assignee2,
]));
});
it('sets assignee', (done) => {
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[2].click();
});
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('second assignee');
expect(vm.board.assignee).toEqual(assignee2);
done();
});
});
});
});
});
/* global boardsMockInterceptor */
/* global BoardService */
import Vue from 'vue';
import '~/labels_select';
import LabelsSelect from '~/boards/components/labels_select.vue';
import IssuableContext from '~/issuable_context';
import '../mock_data';
let vm;
function selectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
function activeDropdownItem(index) {
const items = document.querySelectorAll('.is-active');
if (!items[index]) return '';
return items[index].innerText.trim();
}
const label = {
id: '1',
title: 'Testing',
color: 'red',
description: 'testing;',
};
const label2 = {
id: 2,
title: 'Still Testing',
color: 'red',
description: 'testing;',
};
describe('LabelsSelect', () => {
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
const deferred = new jQuery.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.resolve([
label,
label2,
]));
// eslint-disable-next-line no-new
new IssuableContext();
const propsData = {
board: {
labels: [],
},
canEdit: true,
labelsPath: '/some/path',
};
const Component = Vue.extend(LabelsSelect);
vm = new Component({
propsData,
}).$mount('.test-container');
Vue.nextTick(done);
});
describe('canEdit', () => {
it('hides Edit button', (done) => {
vm.canEdit = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
done();
});
});
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
});
});
describe('selected', () => {
it('shows Any Label', () => {
expect(selectedText()).toContain('Any Label');
});
it('shows single label', (done) => {
vm.board.labels = [label];
Vue.nextTick(() => {
expect(selectedText()).toContain(label.title);
done();
});
});
it('shows multiple labels', (done) => {
vm.board.labels = [label, label2];
Vue.nextTick(() => {
expect(selectedText()).toContain(label.title);
expect(selectedText()).toContain(label2.title);
done();
});
});
});
describe('clicking dropdown items', () => {
it('sets No labels', (done) => {
vm.board.labels = [label];
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[0].click();
});
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('Any Label');
expect(vm.board.labels).toEqual([]);
done();
});
});
it('sets value', (done) => {
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[1].click();
});
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual(label.title);
expect(vm.board.labels[0].title).toEqual(label.title);
done();
});
});
});
});
import Vue from 'vue';
import WeightSelect from '~/boards/components/weight_select.vue';
import IssuableContext from '~/issuable_context';
let vm;
let board;
const weights = ['Any Weight', 'No Weight', 1, 2, 3];
function getSelectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
function activeDropdownItem() {
return vm.$el.querySelector('.is-active').innerText.trim();
}
describe('WeightSelect', () => {
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
board = {
weight: 0,
labels: [],
};
// eslint-disable-next-line no-new
new IssuableContext();
const Component = Vue.extend(WeightSelect);
vm = new Component({
propsData: {
board,
canEdit: true,
weights,
},
}).$mount('.test-container');
Vue.nextTick(done);
});
describe('selected value', () => {
it('defaults to Any Weight', () => {
expect(getSelectedText()).toBe('Any Weight');
});
it('displays Any Weight for value -1', (done) => {
vm.value = -1;
Vue.nextTick(() => {
expect(getSelectedText()).toEqual('Any Weight');
done();
});
});
it('displays No Weight', (done) => {
vm.value = 0;
Vue.nextTick(() => {
expect(getSelectedText()).toEqual('No Weight');
done();
});
});
it('weight 1', (done) => {
vm.value = 1;
Vue.nextTick(() => {
expect(getSelectedText()).toEqual('1');
done();
});
});
});
describe('active item in dropdown', () => {
it('defaults to Any Weight', (done) => {
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
expect(activeDropdownItem()).toEqual('Any Weight');
done();
});
});
it('shows No Weight', (done) => {
vm.value = 0;
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
expect(activeDropdownItem()).toEqual('No Weight');
done();
});
});
it('shows correct weight', (done) => {
vm.value = 1;
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
expect(activeDropdownItem()).toEqual('1');
done();
});
});
});
describe('changing weight', () => {
it('sets value', (done) => {
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[3].click();
});
setTimeout(() => {
expect(activeDropdownItem()).toEqual('2');
expect(board.weight).toEqual('2');
done();
});
});
it('sets Any Weight', (done) => {
vm.value = 2;
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[0].click();
});
setTimeout(() => {
expect(activeDropdownItem()).toEqual('Any Weight');
expect(board.weight).toEqual(-1);
done();
});
});
it('sets No Weight', (done) => {
vm.value = 2;
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[1].click();
});
setTimeout(() => {
expect(activeDropdownItem()).toEqual('No Weight');
expect(board.weight).toEqual(0);
done();
});
});
});
});
......@@ -4,135 +4,147 @@
/* global mockBoardService */
import Vue from 'vue';
import milestoneSelect from '~/boards/components/milestone_select';
import '~/boards/services/board_service';
import '~/boards/stores/boards_store';
import './mock_data';
import MilestoneSelect from '~/boards/components/milestone_select.vue';
import IssuableContext from '~/issuable_context';
describe('Milestone select component', () => {
let selectMilestoneSpy;
let vm;
let vm;
beforeEach(() => {
const MilestoneComp = Vue.extend(milestoneSelect);
function selectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
function activeDropdownItem(index) {
const items = vm.$el.querySelectorAll('.is-active');
if (!items[index]) return '';
return items[index].innerText.trim();
}
selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => {
vm.board.milestone_id = milestone.id;
});
const milestone = {
id: 1,
title: 'first milestone',
name: 'first milestone',
};
const milestone2 = {
id: 2,
title: 'second milestone',
name: 'second milestone',
};
describe('Milestone select component', () => {
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
vm = new MilestoneComp({
// eslint-disable-next-line no-new
new IssuableContext();
const Component = Vue.extend(MilestoneSelect);
vm = new Component({
propsData: {
board: boardObj,
milestonePath: '/test/issue-boards/milestones.json',
selectMilestone: selectMilestoneSpy,
canEdit: true,
},
});
});
}).$mount('.test-container');
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
setTimeout(done);
});
describe('before mount', () => {
it('sets default data', () => {
expect(vm.loading).toBe(false);
expect(vm.milestones.length).toBe(0);
expect(vm.extraMilestones.length).toBe(3);
expect(vm.extraMilestones[0].title).toBe('Any Milestone');
expect(vm.extraMilestones[1].title).toBe('Upcoming');
expect(vm.extraMilestones[2].title).toBe('Started');
describe('canEdit', () => {
it('hides Edit button', (done) => {
vm.canEdit = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
done();
});
});
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
});
});
describe('mounted', () => {
describe('without board milestone', () => {
beforeEach((done) => {
vm.$mount();
describe('selected value', () => {
it('defaults to Any Milestone', () => {
expect(selectedText()).toContain('Any Milestone');
});
setTimeout(() => {
done();
});
it('shows No Milestone', (done) => {
vm.board.milestone_id = 0;
Vue.nextTick(() => {
expect(selectedText()).toContain('No Milestone');
done();
});
});
it('loads data', () => {
expect(vm.milestones.length).toBe(1);
it('shows selected milestone title', (done) => {
vm.board.milestone_id = 20;
vm.board.milestone = {
id: 20,
title: 'Selected milestone',
};
Vue.nextTick(() => {
expect(selectedText()).toContain('Selected milestone');
done();
});
});
it('renders the milestone list', () => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelectorAll('.board-milestone-list li').length).toBe(5);
expect(
vm.$el.querySelectorAll('.board-milestone-list li')[4].textContent,
).toContain('test');
describe('clicking dropdown items', () => {
beforeEach(() => {
const deferred = new jQuery.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.resolve([
milestone,
milestone2,
]));
});
it('selects any milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[0].click();
it('sets Any Milestone', (done) => {
vm.board.milestone_id = 0;
vm.$el.querySelector('.edit-link').click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: null,
title: 'Any Milestone',
setTimeout(() => {
vm.$el.querySelectorAll('li a')[0].click();
});
});
it('selects upcoming milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[1].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: -2,
title: 'Upcoming',
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('Any Milestone');
expect(selectedText()).toEqual('Any Milestone');
done();
});
});
it('selects started milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[2].click();
it('sets No Milestone', (done) => {
vm.$el.querySelector('.edit-link').click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: -3,
title: 'Started',
setTimeout(() => {
vm.$el.querySelectorAll('li a')[1].click();
});
});
it('selects fetched milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[3].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({
id: 1,
title: 'test',
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('No Milestone');
expect(selectedText()).toEqual('No Milestone');
done();
});
});
it('changes selected milestone', (done) => {
const firstLink = vm.$el.querySelectorAll('.board-milestone-list a')[0];
it('sets milestone', (done) => {
vm.$el.querySelector('.edit-link').click();
firstLink.click();
Vue.nextTick(() => {
expect(firstLink.querySelector('.fa-check')).toBeDefined();
done();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[4].click();
});
});
});
describe('with board milestone', () => {
beforeEach((done) => {
vm.board.milestone_id = 1;
vm.$mount();
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('first milestone');
expect(selectedText()).toEqual('first milestone');
expect(vm.board.milestone).toEqual(milestone);
done();
});
});
it('renders the selected milestone', () => {
expect(vm.$el.querySelector('.board-milestone-list .fa-check')).not.toBeNull();
expect(vm.$el.querySelectorAll('.board-milestone-list .fa-check').length).toBe(1);
});
});
});
});
require 'spec_helper'
describe BoardAssignee do
describe 'relationships' do
it { is_expected.to belong_to(:board) }
it { is_expected.to belong_to(:assignee).class_name('User') }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:board) }
it { is_expected.to validate_presence_of(:assignee) }
end
end
require 'spec_helper'
describe BoardLabel do
describe 'validations' do
it { is_expected.to validate_presence_of(:board) }
it { is_expected.to validate_presence_of(:label) }
end
describe 'associations' do
it { is_expected.to belong_to(:label) }
end
end
......@@ -4,6 +4,10 @@ describe Board do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:milestone) }
it { is_expected.to have_one(:board_assignee) }
it { is_expected.to have_one(:assignee).through(:board_assignee) }
it { is_expected.to have_many(:board_labels) }
it { is_expected.to have_many(:labels).through(:board_labels) }
it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
end
......
......@@ -29,9 +29,14 @@ describe API::Boards do
# EE only
set(:milestone) { create(:milestone, project: project) }
set(:board_label) { create(:label, project: project) }
set(:board) do
create(:board, project: project, milestone: milestone, lists: [dev_list, test_list])
create(:board, project: project,
milestone: milestone,
assignee: user,
label_ids: [board_label.id],
lists: [dev_list, test_list])
end
before do
......@@ -60,17 +65,17 @@ describe API::Boards do
end
end
context 'with the issue_board_milestone-feature available' do
it 'returns the milestone when the `issue_board_milestone`-feature is enabled' do
stub_licensed_features(issue_board_milestone: true)
context 'with the scoped_issue_board-feature available' do
it 'returns the milestone when the `scoped_issue_board`-feature is enabled' do
stub_licensed_features(scoped_issue_board: true)
get api(base_url, user)
expect(json_response.first["milestone"]).not_to be_nil
end
it 'hides the milestone when the `issue_board_milestone`-feature is disabled' do
stub_licensed_features(issue_board_milestone: false)
it 'hides the milestone when the `scoped_issue_board`-feature is disabled' do
stub_licensed_features(scoped_issue_board: false)
get api(base_url, user)
......
......@@ -29,5 +29,23 @@ describe Boards::Issues::CreateService do
expect(issue.labels).to eq [label]
end
it 'adds the board assignee, weight, labels and milestone to the issue' do
board_assignee = create(:user)
project.team << [board_assignee, :developer]
board_milestone = create(:milestone, project: project)
board_label = create(:label, project: project)
board.update!(assignee: board_assignee,
milestone: board_milestone,
label_ids: [board_label.id],
weight: 4)
issue = service.execute
expect(issue.assignees).to eq([board_assignee])
expect(issue.weight).to eq(board.weight)
expect(issue.milestone).to eq(board_milestone)
expect(issue.labels).to contain_exactly(label, board_label)
end
end
end
......@@ -25,24 +25,33 @@ describe Boards::UpdateService do
expect(service.execute(board)).to eq false
end
it 'udpates the milestone with issue board milestones enabled' do
stub_licensed_features(issue_board_milestone: true)
it 'updates the configuration params when scoped issue board is enabled' do
stub_licensed_features(scoped_issue_board: true)
assignee = create(:user)
milestone = create(:milestone, project: project)
label = create(:label, project: project)
service = described_class.new(project, double, milestone_id: milestone.id)
service = described_class.new(project, double,
milestone_id: milestone.id,
assignee_id: assignee.id,
label_ids: [label.id])
service.execute(board)
expect(board.reload.milestone).to eq(milestone)
expect(board.reload).to have_attributes(milestone: milestone,
assignee: assignee,
labels: [label])
end
it 'udpates the milestone with the issue board milestones feature enabled' do
stub_licensed_features(issue_board_milestone: false)
milestone = create(:milestone, project: project)
it 'filters unpermitted params when scoped issue board is not enabled' do
stub_licensed_features(scoped_issue_board: false)
params = { milestone_id: double, assignee_id: double, label_ids: double, weight: double }
service = described_class.new(project, double, milestone_id: milestone.id)
service = described_class.new(project, double, params)
service.execute(board)
expect(board.reload.milestone).to be_nil
expect(board.reload).to have_attributes(milestone: nil,
assignee: nil,
labels: [])
end
end
end
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