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 @@ $(() => { ...@@ -68,7 +68,6 @@ $(() => {
rootPath: $boardApp.dataset.rootPath, rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
detailIssue: Store.detail, detailIssue: Store.detail,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
defaultAvatar: $boardApp.dataset.defaultAvatar, defaultAvatar: $boardApp.dataset.defaultAvatar,
}, },
computed: { computed: {
...@@ -77,16 +76,6 @@ $(() => { ...@@ -77,16 +76,6 @@ $(() => {
}, },
}, },
created () { 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({ gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint, boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint, listsEndpoint: this.listsEndpoint,
...@@ -102,7 +91,7 @@ $(() => { ...@@ -102,7 +91,7 @@ $(() => {
eventHub.$off('updateTokens', this.updateTokens); eventHub.$off('updateTokens', this.updateTokens);
}, },
mounted () { mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]); this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
this.filterManager.setup(); this.filterManager.setup();
Store.disabled = this.disabled; Store.disabled = this.disabled;
...@@ -146,6 +135,40 @@ $(() => { ...@@ -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({ gl.IssueBoardsModalAddBtn = new Vue({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
el: document.getElementById('js-add-issues-btn'), 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 { ...@@ -51,10 +51,6 @@ export default {
project_id: this.selectedProject.id, 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}`); eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel(); this.cancel();
......
import Vue from 'vue'; import Vue from 'vue';
import './board_new_form'; import BoardForm from './board_form.vue';
(() => { (() => {
window.gl = window.gl || {}; window.gl = window.gl || {};
...@@ -11,7 +11,7 @@ import './board_new_form'; ...@@ -11,7 +11,7 @@ import './board_new_form';
gl.issueBoards.BoardsSelector = Vue.extend({ gl.issueBoards.BoardsSelector = Vue.extend({
components: { components: {
'board-selector-form': gl.issueBoards.BoardSelectorForm, BoardForm,
}, },
props: { props: {
currentBoard: { currentBoard: {
...@@ -60,19 +60,6 @@ import './board_new_form'; ...@@ -60,19 +60,6 @@ import './board_new_form';
showDelete() { showDelete() {
return this.boards.length > 1; 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: { methods: {
showPage(page) { 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({ ...@@ -30,11 +30,16 @@ gl.issueBoards.ModalFooter = Vue.extend({
const list = this.modal.selectedList || this.state.lists[firstListIndex]; const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues(); const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.id); 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 // Post the data to the backend
gl.boardService.bulkUpdate(issueIds, { gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id], add_label_ids: [list.label.id, ...boardLabelIds],
milestone_id: this.state.currentBoard.milestone_id, milestone_id: currentBoard.milestone_id,
assignee_ids: assigneeIds,
weight: currentBoard.weight,
}).catch(() => { }).catch(() => {
new Flash('Failed to update issues, please try again.', 'alert'); new Flash('Failed to update issues, please try again.', 'alert');
......
...@@ -30,24 +30,43 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ ...@@ -30,24 +30,43 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
}, },
methods: { methods: {
removeIssue() { removeIssue() {
const board = Store.state.currentBoard;
const issue = this.issue; const issue = this.issue;
const lists = issue.getLists(); const lists = issue.getLists();
const boardLabelIds = board.labels.map(label => label.id);
const listLabelIds = lists.map(list => list.label.id); const listLabelIds = lists.map(list => list.label.id);
let labelIds = this.issue.labels
let labelIds = issue.labels
.map(label => label.id) .map(label => label.id)
.filter(id => !listLabelIds.includes(id)); .filter(id => !listLabelIds.includes(id))
.filter(id => !boardLabelIds.includes(id));
if (labelIds.length === 0) { if (labelIds.length === 0) {
labelIds = ['']; 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 = { const data = {
issue: { issue: {
label_ids: labelIds, label_ids: labelIds,
assignee_ids: assigneeIds,
}, },
}; };
if (Store.state.currentBoard.milestone_id) {
if (board.milestone_id) {
data.issue.milestone_id = -1; data.issue.milestone_id = -1;
} }
if (board.weight) {
data.issue.weight = null;
}
// Post the remove data // Post the remove data
Vue.http.patch(this.updateUrl, data).catch(() => { Vue.http.patch(this.updateUrl, data).catch(() => {
new Flash('Failed to remove issue from board, please try again.', 'alert'); 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 { ...@@ -11,7 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async // Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests // instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true; this.isHandledAsync = true;
this.cantEdit = cantEdit; this.cantEdit = cantEdit.filter(i => typeof i === 'string');
this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
} }
updateObject(path) { updateObject(path) {
...@@ -42,7 +43,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { ...@@ -42,7 +43,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new Event('input')); this.filteredSearchInput.dispatchEvent(new Event('input'));
} }
canEdit(tokenName) { canEdit(tokenName, tokenValue) {
return this.cantEdit.indexOf(tokenName) === -1; 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 { ...@@ -20,6 +20,7 @@ class ListIssue {
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id; this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id; this.project_id = obj.project_id;
this.weight = obj.weight;
if (obj.project) { if (obj.project) {
this.project = new IssueProject(obj.project); this.project = new IssueProject(obj.project);
......
...@@ -114,6 +114,8 @@ class List { ...@@ -114,6 +114,8 @@ class List {
issue.iid = data.iid; issue.iid = data.iid;
issue.milestone = data.milestone; issue.milestone = data.milestone;
issue.project = data.project; issue.project = data.project;
issue.assignees = data.assignees;
issue.labels = data.labels;
if (this.issuesSize > 1) { if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id; const moveBeforeId = this.issues[1].id;
......
...@@ -30,10 +30,28 @@ class BoardService { ...@@ -30,10 +30,28 @@ class BoardService {
} }
createBoard (board) { 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) { 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 () { all () {
...@@ -99,17 +117,6 @@ class BoardService { ...@@ -99,17 +117,6 @@ class BoardService {
return this.issues.bulkUpdate(data); 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; window.BoardService = BoardService;
...@@ -13,9 +13,15 @@ gl.issueBoards.BoardsStore = { ...@@ -13,9 +13,15 @@ gl.issueBoards.BoardsStore = {
filter: { filter: {
path: '', path: '',
}, },
state: {}, state: {
currentBoard: {
labels: [],
},
currentPage: '',
reload: false,
},
detail: { detail: {
issue: {} issue: {},
}, },
moving: { moving: {
issue: {}, issue: {},
...@@ -24,13 +30,21 @@ gl.issueBoards.BoardsStore = { ...@@ -24,13 +30,21 @@ gl.issueBoards.BoardsStore = {
create () { create () {
this.state.lists = []; this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&'); this.filter.path = getUrlParamsArray().join('&');
this.detail = { issue: {} }; this.detail = {
issue: {},
};
}, },
createNewListDropdownData() { createNewListDropdownData() {
this.state.currentBoard = {}; this.state.currentBoard = {
labels: [],
};
this.state.currentPage = ''; this.state.currentPage = '';
this.state.reload = false; this.state.reload = false;
}, },
showPage(page) {
this.state.reload = false;
this.state.currentPage = page;
},
addList (listObj, defaultAvatar) { addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar); const list = new List(listObj, defaultAvatar);
this.state.lists.push(list); this.state.lists.push(list);
......
...@@ -147,6 +147,16 @@ class DropdownUtils { ...@@ -147,6 +147,16 @@ class DropdownUtils {
return dataValue !== null; 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) // Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) { static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container; const container = FilteredSearchContainer.container;
......
...@@ -198,8 +198,8 @@ class FilteredSearchManager { ...@@ -198,8 +198,8 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) { if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(lastVisualToken);
const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial();
...@@ -349,8 +349,8 @@ class FilteredSearchManager { ...@@ -349,8 +349,8 @@ class FilteredSearchManager {
let canClearToken = t.classList.contains('js-visual-token'); let canClearToken = t.classList.contains('js-visual-token');
if (canClearToken) { if (canClearToken) {
const tokenKey = t.querySelector('.name').textContent.trim(); const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(t);
canClearToken = this.canEdit && this.canEdit(tokenKey); canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
} }
if (canClearToken) { if (canClearToken) {
...@@ -482,7 +482,7 @@ class FilteredSearchManager { ...@@ -482,7 +482,7 @@ class FilteredSearchManager {
} }
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey); const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
gl.FilteredSearchVisualTokens.addFilterVisualToken( gl.FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey, sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
......
...@@ -38,21 +38,14 @@ class FilteredSearchVisualTokens { ...@@ -38,21 +38,14 @@ class FilteredSearchVisualTokens {
} }
static createVisualTokenElementHTML(canEdit = true) { static createVisualTokenElementHTML(canEdit = true) {
let removeTokenMarkup = '';
if (canEdit) {
removeTokenMarkup = `
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
`;
}
return ` return `
<div class="selectable" role="button"> <div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div> <div class="name"></div>
<div class="value-container"> <div class="value-container">
<div class="value"></div> <div class="value"></div>
${removeTokenMarkup} <div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
</div> </div>
</div> </div>
`; `;
......
...@@ -7,7 +7,7 @@ import DropdownUtils from './filtered_search/dropdown_utils'; ...@@ -7,7 +7,7 @@ import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label'; import CreateLabelDropdown from './create_label';
export default class LabelsSelect { export default class LabelsSelect {
constructor(els) { constructor(els, options = {}) {
var _this, $els; var _this, $els;
_this = this; _this = this;
...@@ -57,6 +57,7 @@ export default class LabelsSelect { ...@@ -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> <% }); %>'); 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>'; labelNoneHTMLTemplate = '<span class="no-value">None</span>';
} }
const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip(); $sidebarLabelTooltip.tooltip();
...@@ -315,9 +316,9 @@ export default class LabelsSelect { ...@@ -315,9 +316,9 @@ export default class LabelsSelect {
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(options) { clicked: function(clickEvent) {
const { $el, e, isMarking } = options; const { $el, e, isMarking } = clickEvent;
const label = options.selectedObj; const label = clickEvent.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel; var isIssueIndex, isMRIndex, page, boardsModel;
var fadeOutLoader = () => { var fadeOutLoader = () => {
...@@ -390,6 +391,10 @@ export default class LabelsSelect { ...@@ -390,6 +391,10 @@ export default class LabelsSelect {
.then(fadeOutLoader) .then(fadeOutLoader)
.catch(fadeOutLoader); .catch(fadeOutLoader);
} }
else if (handleClick) {
e.preventDefault();
handleClick(label);
}
else { else {
if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-multiselect')) {
......
...@@ -5,7 +5,7 @@ import _ from 'underscore'; ...@@ -5,7 +5,7 @@ import _ from 'underscore';
(function() { (function() {
this.MilestoneSelect = (function() { this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject, els) { function MilestoneSelect(currentProject, els, options = {}) {
var _this, $els; var _this, $els;
if (currentProject != null) { if (currentProject != null) {
_this = this; _this = this;
...@@ -141,7 +141,7 @@ import _ from 'underscore'; ...@@ -141,7 +141,7 @@ import _ from 'underscore';
}, },
opened: function(e) { opened: function(e) {
const $el = $(e.currentTarget); 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; selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
} }
$('a.is-active', $el).removeClass('is-active'); $('a.is-active', $el).removeClass('is-active');
...@@ -150,7 +150,8 @@ import _ from 'underscore'; ...@@ -150,7 +150,8 @@ import _ from 'underscore';
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
hideRow: function(milestone) { hideRow: function(milestone) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && 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; return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
} }
...@@ -158,18 +159,26 @@ import _ from 'underscore'; ...@@ -158,18 +159,26 @@ import _ from 'underscore';
}, },
isSelectable: function() { isSelectable: function() {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && 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 false;
} }
return true; return true;
}, },
clicked: function(options) { clicked: function(clickEvent) {
const { $el, e } = options; const { $el, e } = clickEvent;
let selected = options.selectedObj; let selected = clickEvent.selectedObj;
var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
if (!selected) return; if (!selected) return;
if (options.handleClick) {
e.preventDefault();
options.handleClick(selected);
return;
}
page = $('body').attr('data-page'); page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index'); isMRIndex = (page === page && page === 'projects:merge_requests:index');
......
...@@ -6,7 +6,7 @@ import _ from 'underscore'; ...@@ -6,7 +6,7 @@ import _ from 'underscore';
// TODO: remove eventHub hack after code splitting refactor // TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop; window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els) { function UsersSelect(currentUser, els, options = {}) {
var $els; var $els;
this.users = this.users.bind(this); this.users = this.users.bind(this);
this.user = this.user.bind(this); this.user = this.user.bind(this);
...@@ -20,6 +20,8 @@ function UsersSelect(currentUser, els) { ...@@ -20,6 +20,8 @@ function UsersSelect(currentUser, els) {
} }
} }
const { handleClick } = options;
$els = $(els); $els = $(els);
if (!els) { if (!els) {
...@@ -442,6 +444,9 @@ function UsersSelect(currentUser, els) { ...@@ -442,6 +444,9 @@ function UsersSelect(currentUser, els) {
} }
if ($el.closest('.add-issues-modal').length) { if ($el.closest('.add-issues-modal').length) {
gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; 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)) { } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form')); return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) { } else if ($dropdown.hasClass('js-filter-submit')) {
......
...@@ -5,17 +5,27 @@ export default { ...@@ -5,17 +5,27 @@ export default {
props: { props: {
title: { title: {
type: String, type: String,
required: true, required: false,
}, },
text: { text: {
type: String, type: String,
required: false, required: false,
}, },
hideFooter: {
type: Boolean,
required: false,
default: false,
},
kind: { kind: {
type: String, type: String,
required: false, required: false,
default: 'primary', default: 'primary',
}, },
modalDialogClass: {
type: String,
required: false,
default: '',
},
closeKind: { closeKind: {
type: String, type: String,
required: false, required: false,
...@@ -30,6 +40,11 @@ export default { ...@@ -30,6 +40,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
submitDisabled: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...@@ -57,43 +72,58 @@ export default { ...@@ -57,43 +72,58 @@ export default {
</script> </script>
<template> <template>
<div <div class="modal-open">
class="modal popup-dialog" <div
role="dialog" class="modal popup-dialog"
tabindex="-1"> role="dialog"
<div class="modal-dialog" role="document"> tabindex="-1"
<div class="modal-content"> >
<div class="modal-header"> <div
<button type="button" :class="modalDialogClass"
class="close" class="modal-dialog"
@click="close" role="document"
aria-label="Close"> >
<span aria-hidden="true">&times;</span> <div class="modal-content">
</button> <div class="modal-header">
<h4 class="modal-title">{{this.title}}</h4> <slot name="header">
</div> <h4 class="modal-title pull-left">
<div class="modal-body"> {{this.title}}
<slot name="body" :text="text"> </h4>
<p>{{text}}</p> <button
</slot> type="button"
</div> class="close pull-right"
<div class="modal-footer"> @click="close"
<button aria-label="Close"
type="button" >
class="btn" <span aria-hidden="true">&times;</span>
:class="btnCancelKindClass" </button>
@click="close"> </slot>
{{ closeButtonLabel }} </div>
</button> <div class="modal-body">
<button <slot name="body" :text="text">
type="button" <p>{{this.text}}</p>
class="btn" </slot>
:class="btnKindClass" </div>
@click="emitSubmit(true)"> <div class="modal-footer" v-if="!hideFooter">
{{ primaryButtonLabel }} <button
</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>
</div> </div>
<div class="modal-backdrop fade in" />
</div> </div>
</template> </template>
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
(function() { (function() {
this.WeightSelect = (function() { this.WeightSelect = (function() {
function WeightSelect() { function WeightSelect(els, options = {}) {
$('.js-weight-select').each(function(i, dropdown) { const $els = $(els || '.js-weight-select');
$els.each(function(i, dropdown) {
var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, updateUrl, updateWeight; var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, updateUrl, updateWeight;
$dropdown = $(dropdown); $dropdown = $(dropdown);
updateUrl = $dropdown.data('issueUpdate'); updateUrl = $dropdown.data('issueUpdate');
...@@ -13,6 +15,13 @@ ...@@ -13,6 +15,13 @@
$value = $block.find('.value'); $value = $block.find('.value');
abilityName = $dropdown.data('ability-name'); abilityName = $dropdown.data('ability-name');
$loading = $block.find('.block-loading').fadeOut(); $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) { updateWeight = function(selected) {
var data; var data;
data = {}; data = {};
...@@ -39,7 +48,7 @@ ...@@ -39,7 +48,7 @@
}; };
return $dropdown.glDropdown({ return $dropdown.glDropdown({
selectable: true, selectable: true,
fieldName: $dropdown.data("field-name"), fieldName,
toggleLabel: function (selected, el) { toggleLabel: function (selected, el) {
return $(el).data("id"); return $(el).data("id");
}, },
...@@ -54,16 +63,21 @@ ...@@ -54,16 +63,21 @@
return ''; return '';
} }
}, },
clicked: function(options) { clicked: function(glDropdownEvt) {
const e = options.e; const e = glDropdownEvt.e;
let selected = options.selectedObj; 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(); return $(dropdown).parents('form').submit();
} else if ($dropdown.is('.js-issuable-form-weight')) { } else if ($dropdown.is('.js-issuable-form-weight')) {
e.preventDefault(); e.preventDefault();
} else { } else {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); selected = inputField.val();
return updateWeight(selected); return updateWeight(selected);
} }
} }
......
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
.cred { color: $common-red; } .cred { color: $common-red; }
.cgreen { color: $common-green; } .cgreen { color: $common-green; }
.cdark { color: $common-gray-dark; } .cdark { color: $common-gray-dark; }
.text-secondary {
color: $gl-text-color-secondary;
}
.underlined-link { text-decoration: underline; } .underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; } .hint { font-style: italic; color: $hint-color; }
......
...@@ -37,6 +37,7 @@ ...@@ -37,6 +37,7 @@
.dropdown-menu-nav { .dropdown-menu-nav {
@include set-visible; @include set-visible;
display: block; display: block;
min-height: 40px;
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
width: 100%; width: 100%;
......
...@@ -42,3 +42,11 @@ body.modal-open { ...@@ -42,3 +42,11 @@ body.modal-open {
width: 98%; width: 98%;
} }
} }
.modal.popup-dialog {
display: block;
}
.modal-body {
background-color: $modal-body-bg;
}
...@@ -164,3 +164,36 @@ $pre-border-color: $border-color; ...@@ -164,3 +164,36 @@ $pre-border-color: $border-color;
$table-bg-accent: $gray-light; $table-bg-accent: $gray-light;
$zindex-popover: 900; $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 @@ ...@@ -469,6 +469,10 @@
white-space: normal; white-space: normal;
} }
.form-section-title {
font-size: 16px;
}
.board-delete-btns { .board-delete-btns {
padding-top: 12px; padding-top: 12px;
border-top: 1px solid $border-color; border-top: 1px solid $border-color;
...@@ -727,3 +731,16 @@ ...@@ -727,3 +731,16 @@
padding-right: 12px; padding-right: 12px;
color: $gl-gray-dark; 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 ...@@ -127,6 +127,7 @@ module IssuableActions
:milestone_id, :milestone_id,
:state_event, :state_event,
:subscription_event, :subscription_event,
:weight,
label_ids: [], label_ids: [],
add_label_ids: [], add_label_ids: [],
remove_label_ids: [] remove_label_ids: []
......
...@@ -22,17 +22,6 @@ module BoardsHelper ...@@ -22,17 +22,6 @@ module BoardsHelper
project_issues_path(@project) project_issues_path(@project)
end 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 def board_base_url
project_boards_path(@project) project_boards_path(@project)
end 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 ...@@ -30,6 +30,7 @@ class License < ActiveRecord::Base
related_issues related_issues
repository_mirrors repository_mirrors
repository_size_limit repository_size_limit
scoped_issue_board
].freeze ].freeze
EEP_FEATURES = EES_FEATURES + %i[ EEP_FEATURES = EES_FEATURES + %i[
...@@ -70,7 +71,6 @@ class License < ActiveRecord::Base ...@@ -70,7 +71,6 @@ class License < ActiveRecord::Base
group_webhooks group_webhooks
issuable_default_templates issuable_default_templates
issue_board_focus_mode issue_board_focus_mode
issue_board_milestone
issue_weights issue_weights
jenkins_integration jenkins_integration
merge_request_approvers merge_request_approvers
......
...@@ -227,10 +227,6 @@ class Namespace < ActiveRecord::Base ...@@ -227,10 +227,6 @@ class Namespace < ActiveRecord::Base
feature_available?(:multiple_issue_boards) feature_available?(:multiple_issue_boards)
end end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone)
end
private private
def refresh_access_of_projects_invited_groups def refresh_access_of_projects_invited_groups
......
...@@ -1595,10 +1595,6 @@ class Project < ActiveRecord::Base ...@@ -1595,10 +1595,6 @@ class Project < ActiveRecord::Base
feature_available?(:multiple_issue_boards, user) feature_available?(:multiple_issue_boards, user)
end end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone, user)
end
def full_path_was def full_path_was
File.join(namespace.full_path, previous_changes['path'].first) File.join(namespace.full_path, previous_changes['path'].first)
end end
......
...@@ -6,5 +6,10 @@ module Boards ...@@ -6,5 +6,10 @@ module Boards
def initialize(parent, user, params = {}) def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup @parent, @current_user, @params = parent, user, params.dup
end end
def set_assignee
assignee = User.find_by(id: params.delete(:assignee_id))
params.merge!(assignee: assignee)
end
end end
end end
...@@ -13,6 +13,7 @@ module Boards ...@@ -13,6 +13,7 @@ module Boards
end end
def create_board! def create_board!
set_assignee
board = parent.boards.create(params) board = parent.boards.create(params)
if board.persisted? if board.persisted?
......
...@@ -10,11 +10,25 @@ module Boards ...@@ -10,11 +10,25 @@ module Boards
end end
def execute def execute
create_issue(params.merge(label_ids: [list.label_id])) create_issue(creation_params)
end end
private 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 def board
@board ||= parent.boards.find(params.delete(:board_id)) @board ||= parent.boards.find(params.delete(:board_id))
end end
......
module Boards module Boards
class UpdateService < Boards::BaseService class UpdateService < Boards::BaseService
def execute(board) 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) board.update(params)
end end
......
...@@ -5,63 +5,42 @@ ...@@ -5,63 +5,42 @@
%boards-selector{ "inline-template" => true, %boards-selector{ "inline-template" => true,
":current-board" => current_board_json, ":current-board" => current_board_json,
"milestone-path" => milestones_filter_path(milestone_filter_opts) } "milestone-path" => milestones_filter_path(milestone_filter_opts) }
.dropdown %span.boards-selector-wrapper
%button.dropdown-menu-toggle{ "v-on:click" => "loadBoards", .dropdown
data: { toggle: "dropdown" } } %button.dropdown-menu-toggle{ "v-on:click" => "loadBoards",
{{ board.name }} data: { toggle: "dropdown" } }
= icon("chevron-down") {{ board.name }}
.dropdown-menu{ ":class" => "{ 'is-loading': loading }" } = icon("chevron-down")
.dropdown-title .dropdown-menu{ ":class" => "{ 'is-loading': loading }" }
%button.dropdown-title-button.dropdown-menu-back{ type: "button", .dropdown-content
aria: { label: "Go back" }, %ul{ "v-if" => "!loading" }
"v-on:click.stop.prevent" => "showPage('')", %li{ "v-for" => "board in boards" }
"v-if" => "currentPage !== ''" } %a{ ":href" => "'#{board_base_url}/' + board.id" }
= icon("arrow-left") {{ board.name }}
{{ title }} - if !multiple_boards_available? && current_board_parent.boards.size > 1
%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)
%li %li
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('new')" } .small.unclickable
Create new board Some of your boards are hidden, activate a license to see them again.
%li .dropdown-loading{ "v-if" => "loading" }
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('edit')" } = icon("spin spinner")
Edit board name
- if parent.issue_board_milestone_available?(current_user) - if can?(current_user, :admin_board, parent)
%li .dropdown-footer
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('milestone')" } %ul.dropdown-footer-list
Edit board milestone - if parent.feature_available?(:multiple_issue_boards)
%li{ "v-if" => "showDelete" } %li
%a{ "href" => "#", "v-on:click.stop.prevent" => "showPage('delete')" } %a{ "href" => "#", "v-on:click.prevent" => "showPage('new')" }
%span.text-danger Create new board
Delete 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 @@ ...@@ -123,7 +123,10 @@
= icon('times') = icon('times')
.filter-dropdown-container .filter-dropdown-container
- if type == :boards - 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 .dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list 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 ...@@ -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", ["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 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| create_table "boards", force: :cascade do |t|
t.integer "project_id" t.integer "project_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
...@@ -228,6 +242,7 @@ ActiveRecord::Schema.define(version: 20171026082505) do ...@@ -228,6 +242,7 @@ ActiveRecord::Schema.define(version: 20171026082505) do
t.string "name", default: "Development", null: false t.string "name", default: "Development", null: false
t.integer "milestone_id" t.integer "milestone_id"
t.integer "group_id" t.integer "group_id"
t.integer "weight"
end end
add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
...@@ -2196,6 +2211,10 @@ ActiveRecord::Schema.define(version: 20171026082505) do ...@@ -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 "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", 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", "namespaces", column: "group_id", name: "fk_1e9a074a35", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
......
...@@ -11,6 +11,74 @@ class BoardsStoreEE { ...@@ -11,6 +11,74 @@ class BoardsStoreEE {
this.store.removePromotionState = () => { this.store.removePromotionState = () => {
this.removePromotion(); 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() { shouldAddPromotionState() {
......
...@@ -15,7 +15,8 @@ module EE ...@@ -15,7 +15,8 @@ module EE
respond_to do |format| respond_to do |format|
format.json do format.json do
if board.valid? 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 else
render json: board.errors, status: :unprocessable_entity render json: board.errors, status: :unprocessable_entity
end end
...@@ -31,7 +32,8 @@ module EE ...@@ -31,7 +32,8 @@ module EE
respond_to do |format| respond_to do |format|
format.json do format.json do
if @board.valid? 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 else
render json: @board.errors, status: :unprocessable_entity render json: @board.errors, status: :unprocessable_entity
end end
...@@ -44,6 +46,7 @@ module EE ...@@ -44,6 +46,7 @@ module EE
service.execute(@board) service.execute(@board)
respond_to do |format| respond_to do |format|
format.json { head :ok }
format.html { redirect_to boards_path, status: 302 } format.html { redirect_to boards_path, status: 302 }
end end
end end
...@@ -55,7 +58,7 @@ module EE ...@@ -55,7 +58,7 @@ module EE
end end
def board_params def board_params
params.require(:board).permit(:name, :milestone_id) params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
end end
def find_board def find_board
...@@ -74,6 +77,14 @@ module EE ...@@ -74,6 +77,14 @@ module EE
end end
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) def serialize_as_json(resource)
resource.as_json( resource.as_json(
only: [:id, :name], only: [:id, :name],
......
...@@ -5,11 +5,20 @@ module EE ...@@ -5,11 +5,20 @@ module EE
end end
def board_data 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 = { 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, 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) super.merge(data)
...@@ -21,6 +30,19 @@ module EE ...@@ -21,6 +30,19 @@ module EE
"#{group_path(@board.group)}/:project_path/issues" "#{group_path(@board.group)}/:project_path/issues"
end 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 def board_base_url
if board.group_board? if board.group_board?
group_boards_url(@group) group_boards_url(@group)
......
...@@ -3,8 +3,18 @@ module EE ...@@ -3,8 +3,18 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
belongs_to :milestone
belongs_to :group 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 :name, presence: true
validates :group, presence: true, unless: :project validates :group, presence: true, unless: :project
...@@ -14,16 +24,6 @@ module EE ...@@ -14,16 +24,6 @@ module EE
!group !group
end 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 def parent
@parent ||= group || project @parent ||= group || project
end end
...@@ -32,6 +32,19 @@ module EE ...@@ -32,6 +32,19 @@ module EE
group_id.present? group_id.present?
end 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 = {}) def as_json(options = {})
milestone_attrs = options.fetch(:include, {}) milestone_attrs = options.fetch(:include, {})
.extract!(:milestone) .extract!(:milestone)
......
...@@ -12,9 +12,9 @@ ...@@ -12,9 +12,9 @@
- unless @project.feature_available?(:multiple_issue_boards) - unless @project.feature_available?(:multiple_issue_boards)
%li %li
= link_to _('Multiple issue boards'), help_page_path('user/project/issue_board.html', anchor:'use-cases-for-multiple-issue-boards'), target: '_blank' = 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 %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) - unless @project.feature_available?(:issue_board_focus_mode)
%li %li
= link_to _('Issue board focus mode'), help_page_path('user/project/issue_board.html', anchor:'focus-mode'), target: '_blank' = 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 ...@@ -794,11 +794,21 @@ module API
expose :id expose :id
expose :name expose :name
expose :project, using: Entities::BasicProjectDetails 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| expose :lists, using: Entities::List do |board|
board.lists.destroyable board.lists.destroyable
end end
def scoped_issue_available?(board)
board.parent.feature_available?(:scoped_issue_board)
end
end end
class Compare < Grape::Entity class Compare < Grape::Entity
......
...@@ -36,17 +36,39 @@ describe Projects::BoardsController do ...@@ -36,17 +36,39 @@ describe Projects::BoardsController do
end end
context 'with valid params' do 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 it 'returns a successful 200 response' do
create_board name: 'Backend' create_board create_params
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end end
it 'returns the created board' do it 'returns the created board' do
create_board name: 'Backend' create_board create_params
expect(response).to match_response_schema('board') expect(response).to match_response_schema('board')
end 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 end
context 'with invalid params' do context 'with invalid params' do
...@@ -80,34 +102,52 @@ describe Projects::BoardsController do ...@@ -80,34 +102,52 @@ describe Projects::BoardsController do
expect(response).to have_gitlab_http_status(404) expect(response).to have_gitlab_http_status(404)
end end
def create_board(name:) def create_board(board_params)
post :create, namespace_id: project.namespace.to_param, post :create, namespace_id: project.namespace.to_param,
project_id: project.to_param, project_id: project.to_param,
board: { name: name }, board: board_params,
format: :json format: :json
end end
end end
describe 'PATCH update' do describe 'PATCH update' do
let(:board) { create(:board, project: project, name: 'Backend') } 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 context 'with valid params' do
it 'returns a successful 200 response' 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) expect(response).to have_gitlab_http_status(200)
end end
it 'returns the updated board' do it 'returns the updated board' do
update_board board: board, name: 'Frontend' update_board board, update_params
expect(response).to match_response_schema('board') expect(response).to match_response_schema('board')
end 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 end
context 'with invalid params' do context 'with invalid params' do
it 'returns an unprocessable entity 422 response' 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) expect(response).to have_gitlab_http_status(422)
end end
...@@ -115,7 +155,7 @@ describe Projects::BoardsController do ...@@ -115,7 +155,7 @@ describe Projects::BoardsController do
context 'with invalid board id' do context 'with invalid board id' do
it 'returns a not found 404 response' 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) expect(response).to have_gitlab_http_status(404)
end end
...@@ -128,18 +168,18 @@ describe Projects::BoardsController do ...@@ -128,18 +168,18 @@ describe Projects::BoardsController do
end end
it 'returns a not found 404 response' do 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.content_type).to eq 'application/json'
expect(response).to have_gitlab_http_status(404) expect(response).to have_gitlab_http_status(404)
end end
end end
def update_board(board:, name:) def update_board(board, update_params)
patch :update, namespace_id: project.namespace.to_param, patch :update, namespace_id: project.namespace.to_param,
project_id: project.to_param, project_id: project.to_param,
id: board.to_param, id: board.to_param,
board: { name: name }, board: update_params,
format: :json format: :json
end end
end end
......
This diff is collapsed.
...@@ -131,9 +131,8 @@ describe 'Service Desk Issue Tracker', :js do ...@@ -131,9 +131,8 @@ describe 'Service Desk Issue Tracker', :js do
visit service_desk_project_issues_path(project) visit service_desk_project_issues_path(project)
end end
it 'displays the support bot author token' do it 'adds hidden support bot author token' do
author_token = find('.filtered-search-token .value') expect(page).to have_selector('.filtered-search-token .value', text: 'Support Bot', visible: false)
expect(author_token).to have_content('Support Bot')
end end
it 'support bot author token cannot be deleted' do it 'support bot author token cannot be deleted' do
......
require 'spec_helper' require 'spec_helper'
describe Board do describe Board do
let(:board) { create(:board) }
it { is_expected.to include_module(EE::Board) }
context 'validations' do context 'validations' do
context 'when group is present' do context 'when group is present' do
subject { described_class.new(group: create(:group)) } subject { described_class.new(group: create(:group)) }
...@@ -18,11 +22,9 @@ describe Board do ...@@ -18,11 +22,9 @@ describe Board do
end end
describe 'milestone' do describe 'milestone' do
subject(:board) { build(:board) }
context 'when the feature is available' do context 'when the feature is available' do
before do before do
stub_licensed_features(issue_board_milestone: true) stub_licensed_features(scoped_issue_board: true)
end end
it 'returns Milestone::Upcoming for upcoming milestone id' do it 'returns Milestone::Upcoming for upcoming milestone id' do
...@@ -31,6 +33,12 @@ describe Board do ...@@ -31,6 +33,12 @@ describe Board do
expect(board.milestone).to eq Milestone::Upcoming expect(board.milestone).to eq Milestone::Upcoming
end 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 it 'returns milestone for valid milestone id' do
milestone = create(:milestone) milestone = create(:milestone)
board.milestone_id = milestone.id board.milestone_id = milestone.id
...@@ -46,7 +54,7 @@ describe Board do ...@@ -46,7 +54,7 @@ describe Board do
end end
it 'returns nil when the feature is not available' do 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) milestone = create(:milestone)
board.milestone_id = milestone.id 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 ...@@ -51,40 +51,20 @@ describe 'Multiple Issue Boards', :js do
end end
end end
it 'creates new board' do it 'creates new board without detailed configuration' do
click_button board.name click_button board.name
page.within('.dropdown-menu') do page.within('.dropdown-menu') do
click_link 'Create new board' click_link 'Create new board'
fill_in 'board-new-name', with: 'This is a new board'
click_button 'Create'
end end
fill_in 'board-new-name', with: 'This is a new board'
click_button 'Create board'
wait_for_requests wait_for_requests
expect(page).to have_button('This is a new board') expect(page).to have_button('This is a new board')
end 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 it 'deletes board' do
click_button board.name click_button board.name
...@@ -92,16 +72,12 @@ describe 'Multiple Issue Boards', :js do ...@@ -92,16 +72,12 @@ describe 'Multiple Issue Boards', :js do
page.within('.dropdown-menu') do page.within('.dropdown-menu') do
click_link 'Delete board' click_link 'Delete board'
page.within('.dropdown-title') do
expect(page).to have_content('Delete board')
end
click_link 'Delete'
end 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 page.within('.dropdown-menu') do
expect(page).not_to have_content(board.name) expect(page).not_to have_content(board.name)
expect(page).to have_content(board2.name) expect(page).to have_content(board2.name)
...@@ -166,7 +142,6 @@ describe 'Multiple Issue Boards', :js do ...@@ -166,7 +142,6 @@ describe 'Multiple Issue Boards', :js do
page.within('.dropdown-menu') do page.within('.dropdown-menu') do
expect(page).not_to have_content('Create new board') 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') expect(page).not_to have_content('Delete board')
end end
end end
...@@ -188,10 +163,11 @@ describe 'Multiple Issue Boards', :js do ...@@ -188,10 +163,11 @@ describe 'Multiple Issue Boards', :js do
click_button board.name click_button board.name
page.within('.dropdown-menu') do 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('Create new board')
expect(page).not_to have_content('Delete board') expect(page).not_to have_content('Delete board')
end end
expect(page).to have_content('Edit board')
end end
it 'shows a mention that boards are hidden when multiple boards are created' do it 'shows a mention that boards are hidden when multiple boards are created' do
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"name": { "type": "string" }, "name": { "type": "string" },
"board_path": { "type": ["string", "null"] },
"milestone": { "milestone": {
"type": ["object", "null"], "type": ["object", "null"],
"required": [ "required": [
......
...@@ -3,13 +3,17 @@ ...@@ -3,13 +3,17 @@
"required" : [ "required" : [
"id", "id",
"name", "name",
"weight",
"project", "project",
"milestone", "milestone",
"assignee",
"labels",
"lists" "lists"
], ],
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"name": { "type": "string" }, "name": { "type": "string" },
"weight": { "type": ["string", "null"] },
"project": { "project": {
"type": "object", "type": "object",
"required": [ "required": [
...@@ -50,6 +54,13 @@ ...@@ -50,6 +54,13 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"assignee": {
"$ref": "user/basic.json"
},
"labels": {
"type": "array",
"items": { "$ref": "label/basic.json" }
},
"milestone": { "milestone": {
"type": ["object", "null"], "type": ["object", "null"],
"required": [ "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 @@ ...@@ -4,135 +4,147 @@
/* global mockBoardService */ /* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import milestoneSelect from '~/boards/components/milestone_select'; import MilestoneSelect from '~/boards/components/milestone_select.vue';
import '~/boards/services/board_service'; import IssuableContext from '~/issuable_context';
import '~/boards/stores/boards_store';
import './mock_data';
describe('Milestone select component', () => { let vm;
let selectMilestoneSpy;
let vm;
beforeEach(() => { function selectedText() {
const MilestoneComp = Vue.extend(milestoneSelect); return vm.$el.querySelector('.value').innerText.trim();
}
Vue.http.interceptors.push(boardsMockInterceptor); function activeDropdownItem(index) {
gl.boardService = mockBoardService(); const items = vm.$el.querySelectorAll('.is-active');
gl.issueBoards.BoardsStore.create(); if (!items[index]) return '';
return items[index].innerText.trim();
}
selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => { const milestone = {
vm.board.milestone_id = milestone.id; 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: { propsData: {
board: boardObj, board: boardObj,
milestonePath: '/test/issue-boards/milestones.json', milestonePath: '/test/issue-boards/milestones.json',
selectMilestone: selectMilestoneSpy, canEdit: true,
}, },
}); }).$mount('.test-container');
});
afterEach(() => { setTimeout(done);
Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor);
}); });
describe('before mount', () => { describe('canEdit', () => {
it('sets default data', () => { it('hides Edit button', (done) => {
expect(vm.loading).toBe(false); vm.canEdit = false;
expect(vm.milestones.length).toBe(0); Vue.nextTick(() => {
expect(vm.extraMilestones.length).toBe(3); expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
expect(vm.extraMilestones[0].title).toBe('Any Milestone'); done();
expect(vm.extraMilestones[1].title).toBe('Upcoming'); });
expect(vm.extraMilestones[2].title).toBe('Started'); });
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
}); });
}); });
describe('mounted', () => { describe('selected value', () => {
describe('without board milestone', () => { it('defaults to Any Milestone', () => {
beforeEach((done) => { expect(selectedText()).toContain('Any Milestone');
vm.$mount(); });
setTimeout(() => { it('shows No Milestone', (done) => {
done(); vm.board.milestone_id = 0;
}); Vue.nextTick(() => {
expect(selectedText()).toContain('No Milestone');
done();
}); });
});
it('loads data', () => { it('shows selected milestone title', (done) => {
expect(vm.milestones.length).toBe(1); 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', () => { describe('clicking dropdown items', () => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull(); beforeEach(() => {
expect(vm.$el.querySelectorAll('.board-milestone-list li').length).toBe(5); const deferred = new jQuery.Deferred();
expect( spyOn($, 'ajax').and.returnValue(deferred.resolve([
vm.$el.querySelectorAll('.board-milestone-list li')[4].textContent, milestone,
).toContain('test'); milestone2,
]));
}); });
it('selects any milestone', () => { it('sets Any Milestone', (done) => {
vm.$el.querySelectorAll('.board-milestone-list a')[0].click(); vm.board.milestone_id = 0;
vm.$el.querySelector('.edit-link').click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({ setTimeout(() => {
id: null, vm.$el.querySelectorAll('li a')[0].click();
title: 'Any Milestone',
}); });
});
it('selects upcoming milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[1].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({ setTimeout(() => {
id: -2, expect(activeDropdownItem(0)).toEqual('Any Milestone');
title: 'Upcoming', expect(selectedText()).toEqual('Any Milestone');
done();
}); });
}); });
it('selects started milestone', () => { it('sets No Milestone', (done) => {
vm.$el.querySelectorAll('.board-milestone-list a')[2].click(); vm.$el.querySelector('.edit-link').click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({ setTimeout(() => {
id: -3, vm.$el.querySelectorAll('li a')[1].click();
title: 'Started',
}); });
});
it('selects fetched milestone', () => {
vm.$el.querySelectorAll('.board-milestone-list a')[3].click();
expect(selectMilestoneSpy).toHaveBeenCalledWith({ setTimeout(() => {
id: 1, expect(activeDropdownItem(0)).toEqual('No Milestone');
title: 'test', expect(selectedText()).toEqual('No Milestone');
done();
}); });
}); });
it('changes selected milestone', (done) => { it('sets milestone', (done) => {
const firstLink = vm.$el.querySelectorAll('.board-milestone-list a')[0]; vm.$el.querySelector('.edit-link').click();
firstLink.click(); setTimeout(() => {
vm.$el.querySelectorAll('li a')[4].click();
Vue.nextTick(() => {
expect(firstLink.querySelector('.fa-check')).toBeDefined();
done();
}); });
});
});
describe('with board milestone', () => {
beforeEach((done) => {
vm.board.milestone_id = 1;
vm.$mount();
setTimeout(() => { setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('first milestone');
expect(selectedText()).toEqual('first milestone');
expect(vm.board.milestone).toEqual(milestone);
done(); 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 ...@@ -4,6 +4,10 @@ describe Board do
describe 'relationships' do describe 'relationships' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:milestone) } 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) } it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) }
end end
......
...@@ -29,9 +29,14 @@ describe API::Boards do ...@@ -29,9 +29,14 @@ describe API::Boards do
# EE only # EE only
set(:milestone) { create(:milestone, project: project) } set(:milestone) { create(:milestone, project: project) }
set(:board_label) { create(:label, project: project) }
set(:board) do 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 end
before do before do
...@@ -60,17 +65,17 @@ describe API::Boards do ...@@ -60,17 +65,17 @@ describe API::Boards do
end end
end end
context 'with the issue_board_milestone-feature available' do context 'with the scoped_issue_board-feature available' do
it 'returns the milestone when the `issue_board_milestone`-feature is enabled' do it 'returns the milestone when the `scoped_issue_board`-feature is enabled' do
stub_licensed_features(issue_board_milestone: true) stub_licensed_features(scoped_issue_board: true)
get api(base_url, user) get api(base_url, user)
expect(json_response.first["milestone"]).not_to be_nil expect(json_response.first["milestone"]).not_to be_nil
end end
it 'hides the milestone when the `issue_board_milestone`-feature is disabled' do it 'hides the milestone when the `scoped_issue_board`-feature is disabled' do
stub_licensed_features(issue_board_milestone: false) stub_licensed_features(scoped_issue_board: false)
get api(base_url, user) get api(base_url, user)
......
...@@ -29,5 +29,23 @@ describe Boards::Issues::CreateService do ...@@ -29,5 +29,23 @@ describe Boards::Issues::CreateService do
expect(issue.labels).to eq [label] expect(issue.labels).to eq [label]
end 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
end end
...@@ -25,24 +25,33 @@ describe Boards::UpdateService do ...@@ -25,24 +25,33 @@ describe Boards::UpdateService do
expect(service.execute(board)).to eq false expect(service.execute(board)).to eq false
end end
it 'udpates the milestone with issue board milestones enabled' do it 'updates the configuration params when scoped issue board is enabled' do
stub_licensed_features(issue_board_milestone: true) stub_licensed_features(scoped_issue_board: true)
assignee = create(:user)
milestone = create(:milestone, project: project) 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) service.execute(board)
expect(board.reload.milestone).to eq(milestone) expect(board.reload).to have_attributes(milestone: milestone,
assignee: assignee,
labels: [label])
end end
it 'udpates the milestone with the issue board milestones feature enabled' do it 'filters unpermitted params when scoped issue board is not enabled' do
stub_licensed_features(issue_board_milestone: false) stub_licensed_features(scoped_issue_board: false)
milestone = create(:milestone, project: project) 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) service.execute(board)
expect(board.reload.milestone).to be_nil expect(board.reload).to have_attributes(milestone: nil,
assignee: nil,
labels: [])
end end
end 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