Commit 192ae505 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'issue_928_group_boards' into 'master'

Group issue boards

Closes #928

See merge request !2467
parents 91c41c7c c3b7a738
......@@ -6,7 +6,8 @@ const Api = {
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json?simple=true',
labelsPath: '/:namespace_path/:project_path/labels',
projectLabelsPath: '/:namespace_path/:project_path/labels',
groupLabelsPath: '/groups/:namespace_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
......@@ -74,9 +75,16 @@ const Api = {
},
newLabel(namespacePath, projectPath, data, callback) {
const url = Api.buildUrl(Api.labelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
let url;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
return $.ajax({
url,
type: 'POST',
......
......@@ -11,6 +11,7 @@ import './models/issue';
import './models/label';
import './models/list';
import './models/milestone';
import './models/project';
import './models/assignee';
import './stores/boards_store';
import './stores/modal_store';
......@@ -58,7 +59,8 @@ $(() => {
data: {
state: Store.state,
loading: true,
endpoint: $boardApp.dataset.endpoint,
boardsEndpoint: $boardApp.dataset.boardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId,
disabled: $boardApp.dataset.disabled === 'true',
issueLinkBase: $boardApp.dataset.issueLinkBase,
......@@ -84,8 +86,13 @@ $(() => {
Store.updateFiltersUrl(true);
}
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
Store.rootPath = this.endpoint;
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
});
Store.rootPath = this.boardsEndpoint;
this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]);
this.filterManager.setup();
......@@ -149,7 +156,7 @@ $(() => {
focusModeAvailable: gl.utils.convertPermissionToBoolean(
$boardApp.dataset.focusModeAvailable,
),
canAdminList: gl.utils.convertPermissionToBoolean(
canAdminList: this.$options.el && gl.utils.convertPermissionToBoolean(
this.$options.el.dataset.canAdminList,
),
};
......@@ -161,6 +168,9 @@ $(() => {
},
computed: {
disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
......@@ -188,14 +198,6 @@ $(() => {
this.toggleModal(true);
}
},
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
mounted() {
this.updateTooltip();
......@@ -214,6 +216,30 @@ $(() => {
@click="openModal">
Add issues
</button>
</div>
`,
});
gl.IssueBoardsToggleFocusBtn = new Vue({
el: document.getElementById('js-toggle-focus-btn'),
data: {
modal: ModalStore.store,
store: Store.state,
isFullscreen: false,
focusModeAvailable: gl.utils.convertPermissionToBoolean($boardApp.dataset.focusModeAvailable),
},
methods: {
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
template: `
<div class="board-extra-actions">
<a
href="#"
class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
......
......@@ -16,6 +16,7 @@ export default {
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath"
:update-filters="true" />
</li>
......@@ -30,6 +31,7 @@ export default {
disabled: Boolean,
index: Number,
rootPath: String,
groupId: Number,
},
data() {
return {
......
......@@ -9,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore;
export default {
name: 'BoardList',
props: {
groupId: {
type: Number,
required: false,
default: 0,
},
disabled: {
type: Boolean,
required: true,
......@@ -77,7 +82,7 @@ export default {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
if (!this.list.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
this.loadNextPage();
}
},
......@@ -160,17 +165,19 @@ export default {
template: `
<div class="board-list-component">
<div
key="loading"
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
<loading-icon />
</div>
<transition name="slide-down">
<board-new-issue
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
</transition>
<board-new-issue
key="newIssue"
:group-id="groupId"
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
key="list"
class="board-list"
v-show="!loading"
ref="list"
......@@ -183,6 +190,7 @@ export default {
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath"
:disabled="disabled"
:key="issue.id" />
......
/* global ListIssue */
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
const Store = gl.issueBoards.BoardsStore;
export default {
name: 'BoardNewIssue',
props: {
list: Object,
groupId: {
type: Number,
required: false,
default: 0,
},
list: {
type: Object,
required: true,
},
},
data() {
return {
title: '',
error: false,
selectedProject: {},
};
},
components: {
'project-select': ProjectSelect,
},
computed: {
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
},
methods: {
submit(e) {
e.preventDefault();
......@@ -27,6 +48,7 @@ export default {
labels,
subscribed: true,
assignees: [],
project_id: this.selectedProject.id,
});
if (Store.state.currentBoard) {
......@@ -59,43 +81,53 @@ export default {
this.title = '';
eventHub.$emit(`hide-issue-form-${this.list.id}`);
},
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
template: `
<div class="card board-new-issue-form">
<form @submit="submit($event)">
<div class="flash-container"
v-if="error">
<div class="flash-alert">
An error occured. Please try again.
<div class="board-new-issue-form">
<div class="card">
<form @submit="submit($event)">
<div class="flash-container"
v-if="error">
<div class="flash-alert">
An error occured. Please try again.
</div>
</div>
<label class="label-light"
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<project-select
v-if="groupId"
:groupId="groupId"
/>
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="disabled"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div>
</div>
<label class="label-light"
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="title === ''"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div>
</form>
</form>
</div>
</div>
`,
};
......@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false,
default: false,
},
groupId: {
type: Number,
required: false,
},
},
data() {
return {
......@@ -64,10 +68,19 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
return `${this.issueLinkBase}/${this.issue.id}`;
let baseUrl = this.issueLinkBase;
if (this.groupId && this.issue.project) {
baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path);
}
return `${baseUrl}/${this.issue.iid}`;
},
issueId() {
return `#${this.issue.id}`;
if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
......@@ -98,6 +111,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
showLabel(label) {
if (!label.id) return false;
if (this.groupId && label.type === 'ProjectLabel') return false;
return true;
},
filterByLabel(label, e) {
......@@ -143,7 +157,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issue.id"
v-if="issueId"
>
{{ issueId }}
</span>
......
......@@ -29,7 +29,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
const issueIds = selectedIssues.map(issue => issue.id);
// Post the data to the backend
gl.boardService.bulkUpdate(issueIds, {
......
......@@ -27,7 +27,7 @@ gl.issueBoards.newListDropdownInit = () => {
$this.glDropdown({
data(term, callback) {
$.get($this.attr('data-labels'))
$.get($this.attr('data-list-labels-path'))
.then((resp) => {
callback(resp);
});
......
<template>
<div>
<label class="label-light prepend-top-10">
Project
</label>
<div ref="projectsDropdown" class="dropdown">
<button
class="dropdown-menu-toggle wide"
type="button"
data-toggle="dropdown"
aria-expanded="false">
{{ selectedProjectName }}
<i class="fa fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div class="dropdown-title">
<span>Projects</span>
<button aria-label="Close" type="button" class="dropdown-title-button dropdown-menu-close">
<i aria-hidden="true" data-hidden="true" class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
class="dropdown-input-field"
type="search"
placeholder="Search projects">
<i aria-hidden="true" data-hidden="true" class="fa fa-search dropdown-input-search"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</template>
<script>
/* global ListIssue */
import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import Api from '../../api';
export default {
name: 'BoardProjectSelect',
props: {
groupId: {
type: Number,
required: true,
default: 0,
},
},
data() {
return {
loading: true,
selectedProject: {},
};
},
components: {
loadingIcon,
},
computed: {
selectedProjectName() {
return this.selectedProject.name || 'Select a project';
},
},
mounted() {
$(this.$refs.projectsDropdown).glDropdown({
filterable: true,
filterRemote: true,
search: {
fields: ['name_with_namespace'],
},
clicked: ({ $el, e }) => {
e.preventDefault();
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
selectable: true,
data: (term, callback) => {
this.loading = true;
return Api.groupProjects(this.groupId, term, (projects) => {
this.loading = false;
callback(projects);
});
},
renderRow(project) {
return `
<li>
<a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}">
${_.escape(project.name)}
</a>
</li>
`;
},
text: project => project.name,
});
},
};
</script>
......@@ -18,22 +18,38 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate.replace(':project_path', this.issue.project.path);
},
},
methods: {
removeIssue() {
const issue = this.issue;
const lists = issue.getLists();
const labelIds = lists.map(list => list.label.id);
const listLabelIds = lists.map(list => list.label.id);
let labelIds = this.issue.labels
.map(label => label.id)
.filter(id => !listLabelIds.includes(id));
if (labelIds.length === 0) {
labelIds = [''];
}
const data = {
remove_label_ids: labelIds,
issue: {
label_ids: labelIds,
},
};
if (Store.state.currentBoard.milestone_id) {
data.milestone_id = -1;
data.issue.milestone_id = -1;
}
// Post the remove data
gl.boardService.bulkUpdate([issue.globalId], data).catch(() => {
Vue.http.patch(this.updateUrl, data).catch(() => {
new Flash('Failed to remove issue from board, please try again.', 'alert');
lists.forEach((list) => {
......
......@@ -4,11 +4,12 @@
/* global ListAssignee */
import Vue from 'vue';
import IssueProject from './project';
class ListIssue {
constructor (obj, defaultAvatar) {
this.globalId = obj.id;
this.id = obj.iid;
this.id = obj.id;
this.iid = obj.iid;
this.title = obj.title;
this.confidential = obj.confidential;
this.dueDate = obj.due_date;
......@@ -18,6 +19,11 @@ class ListIssue {
this.selected = false;
this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
if (obj.project) {
this.project = new IssueProject(obj.project);
}
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
......@@ -88,7 +94,8 @@ class ListIssue {
data.issue.label_ids = [''];
}
return Vue.http.patch(url, data);
const projectPath = this.project ? this.project.path : '';
return Vue.http.patch(url.replace(':project_path', projectPath), data);
}
}
......
......@@ -4,6 +4,7 @@ class ListLabel {
constructor (obj) {
this.id = obj.id;
this.title = obj.title;
this.type = obj.type;
this.color = obj.color;
this.textColor = obj.text_color;
this.description = obj.description;
......
......@@ -110,12 +110,14 @@ class List {
return gl.boardService.newIssue(this.id, issue)
.then(resp => resp.json())
.then((data) => {
issue.id = data.iid;
issue.id = data.id;
issue.iid = data.iid;
issue.milestone = data.milestone;
issue.project = data.project;
if (this.issuesSize > 1) {
const moveBeforeIid = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
const moveBeforeId = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
}
});
}
......@@ -127,19 +129,19 @@ class List {
}
addIssue (issue, listFrom, newIndex) {
let moveBeforeIid = null;
let moveAfterIid = null;
let moveBeforeId = null;
let moveAfterId = null;
if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) {
moveBeforeIid = this.issues[newIndex - 1].id;
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex + 1]) {
moveAfterIid = this.issues[newIndex + 1].id;
moveAfterId = this.issues[newIndex + 1].id;
}
} else {
this.issues.push(issue);
......@@ -152,30 +154,30 @@ class List {
if (listFrom) {
this.issuesSize += 1;
this.updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid);
this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
}
}
}
moveIssue (issue, oldIndex, newIndex, moveBeforeIid, moveAfterIid) {
moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid)
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
findIssue (id) {
return this.issues.filter(issue => issue.id === id)[0];
return this.issues.find(issue => issue.id === id);
}
removeIssue (removeIssue) {
......
export default class IssueProject {
constructor(obj) {
this.id = obj.id;
this.path = obj.path;
}
}
......@@ -3,21 +3,21 @@
import Vue from 'vue';
class BoardService {
constructor (root, bulkUpdatePath, boardId) {
this.boards = Vue.resource(`${root}{/id}.json`, {}, {
constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: {
method: 'GET',
url: `${root}/${boardId}/issues.json`
url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
}
});
this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
generate: {
method: 'POST',
url: `${root}/${boardId}/lists/generate.json`
url: `${listsEndpoint}/generate.json`
}
});
this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, {
this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: {
method: 'POST',
url: bulkUpdatePath,
......@@ -71,12 +71,12 @@ class BoardService {
return this.issues.get(data);
}
moveIssue (id, from_list_id = null, to_list_id = null, move_before_iid = null, move_after_iid = null) {
moveIssue (id, from_list_id = null, to_list_id = null, move_before_id = null, move_after_id = null) {
return this.issue.update({ id }, {
from_list_id,
to_list_id,
move_before_iid,
move_after_iid,
move_before_id,
move_after_id,
});
}
......
......@@ -15,8 +15,8 @@ class DropdownUser extends gl.FilteredSearchDropdown {
params: {
per_page: 20,
active: true,
project_id: this.getProjectId(),
current_user: true,
...this.projectOrGroupId(),
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
......@@ -47,10 +47,27 @@ class DropdownUser extends gl.FilteredSearchDropdown {
super.renderContent(forceShowList);
}
getGroupId() {
return this.input.getAttribute('data-group-id');
}
getProjectId() {
return this.input.getAttribute('data-project-id');
}
projectOrGroupId() {
const projectId = this.getProjectId();
const groupId = this.getGroupId();
if (groupId) {
return {
group_id: groupId,
};
}
return {
project_id: projectId,
};
}
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
......
......@@ -2,13 +2,14 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', tokenizer, page) {
constructor(baseEndpoint = '', tokenizer, page, isGroup) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
this.groupsOnly = page === 'boards' && isGroup;
if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE;
......@@ -47,7 +48,7 @@ class FilteredSearchDropdownManager {
reference: null,
gl: 'DropdownNonUser',
extraArguments: {
endpoint: `${this.baseEndpoint}/milestones.json`,
endpoint: `${this.baseEndpoint}/milestones.json${this.groupsOnly ? '?only_group_milestones=true' : ''}`,
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
......@@ -56,7 +57,7 @@ class FilteredSearchDropdownManager {
reference: null,
gl: 'DropdownNonUser',
extraArguments: {
endpoint: `${this.baseEndpoint}/labels.json`,
endpoint: `${this.baseEndpoint}/labels.json${this.groupsOnly ? '?only_group_labels=true' : ''}`,
symbol: '~',
preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
},
......
......@@ -63,7 +63,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.dropdownManager = new gl.FilteredSearchDropdownManager(
this.filteredSearchInput.getAttribute('data-base-endpoint') || '',
this.tokenizer,
this.page,
Boolean(this.filteredSearchInput.getAttribute('data-group-id')),
);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
......
......@@ -18,14 +18,20 @@ class Subscription {
}
button.classList.add('disabled');
// hack to allow this to work with the issue boards Vue object
const isBoardsPage = document.querySelector('html').classList.contains('issue-boards-page');
const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
const toggleActionUrl = this.containerElm.dataset.url;
let toggleActionUrl = this.containerElm.dataset.url;
if (isBoardsPage) {
toggleActionUrl = toggleActionUrl.replace(':project_path', gl.issueBoards.BoardsStore.detail.issue.project.path);
}
$.post(toggleActionUrl, () => {
button.classList.remove('disabled');
// hack to allow this to work with the issue boards Vue object
if (document.querySelector('html').classList.contains('issue-boards-page')) {
if (isBoardsPage) {
gl.issueBoards.boardStoreIssueSet(
'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed,
......
......@@ -183,7 +183,7 @@
width: auto;
top: 100%;
left: 0;
z-index: 200;
z-index: 300;
min-width: 240px;
max-width: 500px;
margin-top: 2px;
......
......@@ -147,13 +147,12 @@
}
.board-title {
position: initial;
padding: 0;
border-bottom: 0;
> span {
display: block;
transform: rotate(90deg) translate(25px, 0);
transform: rotate(90deg) translate(35px, 10px);
}
}
......@@ -181,11 +180,18 @@
}
.board-header {
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
position: relative;
&.has-border {
&.has-border::before {
border-top: 3px solid;
border-color: inherit;
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
content: '';
position: absolute;
width: calc(100% + 2px);
top: 0;
left: 0;
margin-top: -1px;
margin-right: -1px;
margin-left: -1px;
......@@ -206,12 +212,16 @@
}
.board-title {
position: relative;
margin: 0;
padding: $gl-padding;
padding-bottom: ($gl-padding + 3px);
padding: 12px $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
display: flex;
align-items: center;
}
.board-title-text {
margin-right: auto;
}
.board-delete {
......@@ -259,43 +269,10 @@
}
}
.slide-down-enter {
transform: translateY(-100%);
}
.slide-down-enter-active {
transition: transform $fade-in-duration;
+ .board-list {
transform: translateY(-136px);
transition: none;
}
}
.slide-down-enter-to {
+ .board-list {
transform: translateY(0);
transition: transform $fade-in-duration ease;
}
}
.slide-down-leave {
transform: translateY(0);
}
.slide-down-leave-active {
transition: all $fade-in-duration;
transform: translateY(-136px);
+ .board-list {
transition: transform $fade-in-duration ease;
transform: translateY(-136px);
}
}
.board-list-component {
height: calc(100% - 49px);
overflow: hidden;
position: relative;
}
.board-list {
......@@ -466,7 +443,7 @@
}
.board-new-issue-form {
z-index: 1;
z-index: 4;
margin: 5px;
}
......
module Boards
class ApplicationController < ::ApplicationController
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def board
@board ||= Board.find(params[:board_id])
end
def board_parent
@board_parent ||= board.parent
end
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
end
module Boards
class IssuesController < Boards::ApplicationController
prepend EE::BoardsResponses
prepend EE::Boards::IssuesController
include BoardsResponses
before_action :authorize_read_issue, only: [:index]
before_action :authorize_create_issue, only: [:create]
before_action :authorize_update_issue, only: [:update]
skip_before_action :authenticate_user!, only: [:index]
def index
issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20)
make_sure_position_is_set(issues) unless Gitlab::Geo.secondary?
render json: {
issues: serialize_as_json(issues.preload(:project)),
size: issues.total_count
}
end
def create
service = Boards::Issues::CreateService.new(board_parent, project, current_user, issue_params)
issue = service.execute
if issue.valid?
render json: serialize_as_json(issue)
else
render json: issue.errors, status: :unprocessable_entity
end
end
def update
service = Boards::Issues::MoveService.new(board_parent, current_user, move_params)
if service.execute(issue)
head :ok
else
head :unprocessable_entity
end
end
private
def make_sure_position_is_set(issues)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
end
end
def issue
@issue ||= issues_finder.execute.find(params[:id])
end
def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
def issues_finder
IssuesFinder.new(current_user, project_id: board_parent.id)
end
def project
board_parent
end
def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id)
end
def issue_params
params.require(:issue)
.permit(:title, :milestone_id, :project_id)
.merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
end
def serialize_as_json(resource)
resource.as_json(
labels: true,
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
end
end
module Boards
class ListsController < Boards::ApplicationController
prepend EE::BoardsResponses
include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index]
def index
lists = Boards::Lists::ListService.new(board.parent, current_user).execute(board)
render json: serialize_as_json(lists)
end
def create
list = Boards::Lists::CreateService.new(board.parent, current_user, list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
list = board.lists.movable.find(params[:id])
service = Boards::Lists::MoveService.new(board_parent, current_user, move_params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def destroy
list = board.lists.destroyable.find(params[:id])
service = Boards::Lists::DestroyService.new(board_parent, current_user)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def generate
service = Boards::Lists::GenerateService.new(board_parent, current_user)
if service.execute(board)
render json: serialize_as_json(board.lists.movable)
else
head :unprocessable_entity
end
end
private
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position)
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
end
end
end
module BoardsResponses
def authorize_read_list
authorize_action_for!(board.parent, :read_list)
end
def authorize_read_issue
authorize_action_for!(board.parent, :read_issue)
end
def authorize_update_issue
authorize_action_for!(issue, :admin_issue)
end
def authorize_create_issue
authorize_action_for!(project, :admin_issue)
end
def authorize_admin_list
authorize_action_for!(board.parent, :admin_list)
end
def authorize_action_for!(resource, ability)
return render_403 unless can?(current_user, ability, resource)
end
def respond_with_boards
respond_with(@boards)
end
def respond_with_board
respond_with(@board)
end
def respond_with(resource)
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(resource)
end
end
end
end
......@@ -14,7 +14,13 @@ class Groups::LabelsController < Groups::ApplicationController
end
format.json do
available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
available_labels =
if params[:only_group_labels]
group.labels
else
LabelsFinder.new(current_user, group_id: @group.id).execute
end
render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
......@@ -28,10 +34,18 @@ class Groups::LabelsController < Groups::ApplicationController
def create
@label = Labels::CreateService.new(label_params).execute(group: group)
if @label.valid?
redirect_to group_labels_path(@group)
else
render :new
respond_to do |format|
format.html do
if @label.valid?
redirect_to group_labels_path(@group)
else
render :new
end
end
format.json do
render json: LabelSerializer.new.represent_appearance(@label)
end
end
end
......
......@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
render json: milestones.map { |m| m.for_display.slice(:title, :name) }
render json: milestones.map { |m| m.for_display.slice(:title, :name, :id) }
end
end
end
......@@ -78,7 +78,13 @@ class Groups::MilestonesController < Groups::ApplicationController
search_params = params.merge(group_ids: group.id)
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
legacy_milestones =
if params[:only_group_milestones]
[]
else
GroupMilestone.build_collection(group, group_projects, params)
end
milestones + legacy_milestones
end
......
module Projects
module Boards
class ApplicationController < Projects::ApplicationController
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
end
end
module Projects
module Boards
class IssuesController < Boards::ApplicationController
before_action :authorize_read_issue!, only: [:index]
before_action :authorize_create_issue!, only: [:create]
before_action :authorize_update_issue!, only: [:update]
def index
issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20)
make_sure_position_is_set(issues) unless Gitlab::Geo.secondary?
render json: {
issues: serialize_as_json(issues),
size: issues.total_count
}
end
def create
service = ::Boards::Issues::CreateService.new(project, current_user, issue_params)
issue = service.execute
if issue.valid?
render json: serialize_as_json(issue)
else
render json: issue.errors, status: :unprocessable_entity
end
end
def update
service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
if service.execute(issue)
head :ok
else
head :unprocessable_entity
end
end
private
def make_sure_position_is_set(issues)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
end
end
def issue
@issue ||=
IssuesFinder.new(current_user, project_id: project.id)
.execute
.where(iid: params[:id])
.first!
end
def authorize_read_issue!
return render_403 unless can?(current_user, :read_issue, project)
end
def authorize_create_issue!
return render_403 unless can?(current_user, :admin_issue, project)
end
def authorize_update_issue!
return render_403 unless can?(current_user, :update_issue, issue)
end
def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_iid, :move_after_iid)
end
def issue_params
params.require(:issue).permit(:title, :milestone_id).merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
end
def serialize_as_json(resource)
resource.as_json(
labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: {
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
end
end
end
module Projects
module Boards
class ListsController < Boards::ApplicationController
before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
before_action :authorize_read_list!, only: [:index]
def index
lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
render json: serialize_as_json(lists)
end
def create
list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
list = board.lists.movable.find(params[:id])
service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def destroy
list = board.lists.destroyable.find(params[:id])
service = ::Boards::Lists::DestroyService.new(project, current_user)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def generate
service = ::Boards::Lists::GenerateService.new(project, current_user)
if service.execute(board)
render json: serialize_as_json(board.lists.movable)
else
head :unprocessable_entity
end
end
private
def authorize_admin_list!
return render_403 unless can?(current_user, :admin_list, project)
end
def authorize_read_list!
return render_403 unless can?(current_user, :read_list, project)
end
def board
@board ||= project.boards.find(params[:board_id])
end
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position)
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
end
end
end
end
class Projects::BoardsController < Projects::ApplicationController
prepend EE::Projects::BoardsController
prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses
include IssuableCollections
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
def index
@boards = ::Boards::ListService.new(project, current_user).execute
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(@boards)
end
end
@boards = Boards::ListService.new(project, current_user).execute
respond_with_boards
end
def show
@board = project.boards.find(params[:id])
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(@board)
end
end
respond_with_board
end
private
def assign_endpoint_vars
@boards_endpoint = project_boards_url(project)
@bulk_issues_path = bulk_update_project_issues_path(project)
@namespace_path = project.namespace.full_path
@labels_endpoint = project_labels_path(project)
end
def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project)
end
......
module BoardsHelper
prepend EE::BoardsHelper
def board_data
board = @board || @boards.first
def board
@board ||= @board || @boards.first
end
def board_data
{
endpoint: project_boards_path(@project),
boards_endpoint: @boards_endpoint,
lists_endpoint: board_lists_url(board),
board_id: board.id,
board_milestone_title: board&.milestone&.title,
disabled: "#{!can?(current_user, :admin_list, @project)}",
issue_link_base: project_issues_path(@project),
disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
issue_link_base: build_issue_link_base,
root_path: root_path,
bulk_update_path: bulk_update_project_issues_path(@project),
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar)
}
end
def build_issue_link_base
project_issues_path(@project)
end
def current_board_json
board = @board || @boards.first
......@@ -26,4 +32,51 @@ module BoardsHelper
}
)
end
def board_base_url
project_boards_path(@project)
end
def multiple_boards_available?
current_board_parent.multiple_issue_boards_available?(current_user)
end
def current_board_path(board)
@current_board_path ||= project_board_path(current_board_parent, board)
end
def current_board_parent
@current_board_parent ||= @project
end
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def board_list_data
{
toggle: "dropdown",
list_labels_path: labels_filter_path(true),
labels: labels_filter_path(true),
labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path,
project_path: @project&.try(:path)
}
end
def board_sidebar_user_data
dropdown_options = issue_assignees_dropdown_options
{
toggle: 'dropdown',
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
project_id: @project&.try(:id),
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
'max-select': dropdown_options[:data][:'max-select']
}
end
end
......@@ -29,7 +29,7 @@ module FormHelper
first_user: current_user&.username,
null_user: true,
current_user: true,
project_id: @project.id,
project_id: @project&.id,
field_name: "issue[assignee_ids][]",
default_label: 'Unassigned',
'max-select': 1,
......
......@@ -369,6 +369,14 @@ module IssuablesHelper
end
end
def labels_path
if @project
project_labels_path(@project)
elsif @group
group_labels_path(@group)
end
end
def issuable_sidebar_options(issuable, can_edit_issuable)
{
endpoint: "#{issuable_json_path(issuable)}?basic=true",
......
......@@ -121,13 +121,14 @@ module LabelsHelper
end
end
def labels_filter_path
return group_labels_path(@group, :json) if @group
def labels_filter_path(only_group_labels = false)
project = @target_project || @project
if project
project_labels_path(project, :json)
elsif @group
options = { only_group_labels: only_group_labels } if only_group_labels
group_labels_path(@group, :json, options)
else
dashboard_labels_path(:json)
end
......
......@@ -137,19 +137,21 @@ module SearchHelper
end
def search_filter_input_options(type)
opts = {
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'username-params' => @users.to_json(only: [:id, :username])
opts =
{
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'username-params' => @users.to_json(only: [:id, :username])
}
}
}
if @project.present?
opts[:data]['project-id'] = @project.id
opts[:data]['base-endpoint'] = project_path(@project)
else
# Group context
opts[:data]['group-id'] = @group.id
opts[:data]['base-endpoint'] = group_canonical_path(@group)
end
......
......@@ -5,7 +5,13 @@ class Board < ActiveRecord::Base
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :name, :project, presence: true
validates :name, presence: true
validates :project, presence: true, if: :project_needed?
def project_needed?
true
end
def backlog_list
lists.merge(List.backlog).take
......
......@@ -10,8 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours
end
def project_ids
[project.id]
end
def max_relative_position
self.class.in_projects(project.id).maximum(:relative_position)
self.class.in_projects(project_ids).maximum(:relative_position)
end
def prev_relative_position
......@@ -19,7 +23,7 @@ module RelativePositioning
if self.relative_position
prev_pos = self.class
.in_projects(project.id)
.in_projects(project_ids)
.where('relative_position < ?', self.relative_position)
.maximum(:relative_position)
end
......@@ -32,7 +36,7 @@ module RelativePositioning
if self.relative_position
next_pos = self.class
.in_projects(project.id)
.in_projects(project_ids)
.where('relative_position > ?', self.relative_position)
.minimum(:relative_position)
end
......@@ -59,7 +63,7 @@ module RelativePositioning
pos_after = before.next_relative_position
if before.shift_after?
issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_after)
issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_after)
issue_to_move.move_after
@positionable_neighbours = [issue_to_move]
......@@ -74,7 +78,7 @@ module RelativePositioning
pos_before = after.prev_relative_position
if after.shift_before?
issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_before)
issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_before)
issue_to_move.move_before
@positionable_neighbours = [issue_to_move]
......
......@@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
prepend EE::Issue
prepend EE::RelativePositioning
include InternalId
include Issuable
......
class Label < ActiveRecord::Base
# EE specific
prepend EE::Label
include CacheMarkdownField
include Referable
include Subscribable
......@@ -34,7 +37,8 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) }
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project)
joins(:priorities)
......@@ -172,6 +176,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
json[:type] = self.try(:type)
json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
......
......@@ -18,6 +18,7 @@ class License < ActiveRecord::Base
ISSUABLE_DEFAULT_TEMPLATES_FEATURE = 'GitLab_IssuableDefaultTemplates'.freeze
ISSUE_BOARD_FOCUS_MODE_FEATURE = 'GitLab_IssueBoardFocusMode'.freeze
ISSUE_BOARD_MILESTONE_FEATURE = 'GitLab_IssueBoardMilestone'.freeze
GROUP_ISSUE_BOARDS_FEATURE = 'GitLab_GroupIssueBoards'.freeze
ISSUE_WEIGHTS_FEATURE = 'GitLab_IssueWeights'.freeze
JENKINS_INTEGRATION_FEATURE = 'GitLab_JenkinsIntegration'.freeze
JIRA_DEV_PANEL_INTEGRATION_FEATURE = 'GitLab_JiraDevelopmentPanelIntegration'.freeze
......@@ -62,6 +63,7 @@ class License < ActiveRecord::Base
issuable_default_templates: ISSUABLE_DEFAULT_TEMPLATES_FEATURE,
issue_board_focus_mode: ISSUE_BOARD_FOCUS_MODE_FEATURE,
issue_board_milestone: ISSUE_BOARD_MILESTONE_FEATURE,
group_issue_boards: GROUP_ISSUE_BOARDS_FEATURE,
issue_weights: ISSUE_WEIGHTS_FEATURE,
jenkins_integration: JENKINS_INTEGRATION_FEATURE,
jira_dev_panel_integration: JIRA_DEV_PANEL_INTEGRATION_FEATURE,
......@@ -118,7 +120,8 @@ class License < ActiveRecord::Base
{ OBJECT_STORAGE_FEATURE => 1 },
{ JIRA_DEV_PANEL_INTEGRATION_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 },
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 }
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 },
{ GROUP_ISSUE_BOARDS_FEATURE => 1 }
].freeze
EEU_FEATURES = [
......
......@@ -210,6 +210,14 @@ class Namespace < ActiveRecord::Base
self.deleted_at = Time.now
end
def multiple_issue_boards_available?(user = nil)
feature_available?(:multiple_issue_boards)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone)
end
private
def refresh_access_of_projects_invited_groups
......
......@@ -1470,6 +1470,14 @@ class Project < ActiveRecord::Base
end
end
def multiple_issue_boards_available?(user)
feature_available?(:multiple_issue_boards, user)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone, user)
end
def full_path_was
File.join(namespace.full_path, previous_changes['path'].first)
end
......
......@@ -24,9 +24,18 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
rule { public_group } .enable :read_group
rule { public_group }.policy do
enable :read_group
enable :read_list
end
rule { logged_in_viewable }.enable :read_group
rule { guest } .enable :read_group
rule { guest }.policy do
enable :read_group
enable :read_list
end
rule { admin } .enable :read_group
rule { has_projects } .enable :read_group
......
module Boards
class BaseService < ::BaseService
# Parent can either a group or a project
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
end
end
module Boards
class CreateService < BaseService
class CreateService < Boards::BaseService
prepend EE::Boards::CreateService
def execute
......@@ -9,11 +9,11 @@ module Boards
private
def can_create_board?
project.boards.size == 0
parent.boards.size == 0
end
def create_board!
board = project.boards.create(params)
board = parent.boards.create(params)
if board.persisted?
board.lists.create(list_type: :backlog)
......
module Boards
class DestroyService < BaseService
class DestroyService < Boards::BaseService
def execute(board)
return false if project.boards.size == 1
return false if parent.boards.size == 1
board.destroy
end
......
module Boards
module Issues
class CreateService < BaseService
class CreateService < Boards::BaseService
attr_accessor :project
def initialize(parent, project, user, params = {})
@project = project
super(parent, user, params)
end
def execute
create_issue(params.merge(label_ids: [list.label_id]))
end
......@@ -8,7 +16,7 @@ module Boards
private
def board
@board ||= project.boards.find(params.delete(:board_id))
@board ||= parent.boards.find(params.delete(:board_id))
end
def list
......
module Boards
module Issues
class ListService < BaseService
class ListService < Boards::BaseService
prepend EE::Boards::Issues::ListService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
......@@ -11,7 +13,7 @@ module Boards
private
def board
@board ||= project.boards.find(params[:board_id])
@board ||= parent.boards.find(params[:board_id])
end
def list
......@@ -33,14 +35,13 @@ module Boards
end
def filter_params
set_project
set_parent
set_state
params
end
def set_project
params[:project_id] = project.id
def set_parent
params[:project_id] = parent.id
end
def set_state
......
module Boards
module Issues
class MoveService < BaseService
class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty?
update_service.execute(issue)
update(issue)
end
private
def board
@board ||= project.boards.find(params[:board_id])
@board ||= parent.boards.find(params[:board_id])
end
def move_between_lists?
......@@ -27,8 +29,8 @@ module Boards
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end
def update_service
::Issues::UpdateService.new(project, current_user, issue_params)
def update(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
end
def issue_params
......@@ -42,7 +44,7 @@ module Boards
)
end
attrs[:move_between_iids] = move_between_iids if move_between_iids
attrs[:move_between_ids] = move_between_ids if move_between_ids
attrs
end
......@@ -61,16 +63,16 @@ module Boards
if moving_to_list.movable?
moving_from_list.label_id
else
Label.on_project_boards(project.id).pluck(:label_id)
Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
end
def move_between_iids
return unless params[:move_after_iid] || params[:move_before_iid]
def move_between_ids
return unless params[:move_after_id] || params[:move_before_id]
[params[:move_after_iid], params[:move_before_iid]]
[params[:move_after_id], params[:move_before_id]]
end
end
end
......
module Boards
class ListService < BaseService
class ListService < Boards::BaseService
prepend EE::Boards::ListService
def execute
create_board! if project.boards.empty?
project.boards
create_board! if parent.boards.empty?
parent.boards
end
private
def create_board!
Boards::CreateService.new(project, current_user).execute
Boards::CreateService.new(parent, current_user).execute
end
end
end
module Boards
module Lists
class CreateService < BaseService
class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
def execute(board)
List.transaction do
label = available_labels.find(params[:label_id])
label = available_labels_for(board).find(params[:label_id])
position = next_position(board)
create_list(board, label, position)
end
end
private
def available_labels
LabelsFinder.new(current_user, project_id: project.id).execute
def available_labels_for(board)
LabelsFinder.new(current_user, project_id: parent.id).execute
end
def next_position(board)
......
module Boards
module Lists
class DestroyService < BaseService
class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?
......
module Boards
module Lists
class GenerateService < BaseService
class GenerateService < Boards::BaseService
def execute(board)
return false unless board.lists.movable.empty?
......@@ -15,11 +15,11 @@ module Boards
def create_list(board, params)
label = find_or_create_label(params)
Lists::CreateService.new(project, current_user, label_id: label.id).execute(board)
Lists::CreateService.new(parent, current_user, label_id: label.id).execute(board)
end
def find_or_create_label(params)
::Labels::FindOrCreateService.new(current_user, project, params).execute
::Labels::FindOrCreateService.new(current_user, parent, params).execute
end
def label_params
......
module Boards
module Lists
class ListService < BaseService
class ListService < Boards::BaseService
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
module Boards
module Lists
class MoveService < BaseService
class MoveService < Boards::BaseService
def execute(list)
@board = list.board
@old_position = list.position
......
module Boards
class UpdateService < BaseService
class UpdateService < Boards::BaseService
def execute(board)
params.delete(:milestone_id) unless project.feature_available?(:issue_board_milestone)
params.delete(:milestone_id) unless parent.feature_available?(:issue_board_milestone)
board.update(params)
end
......
......@@ -3,7 +3,7 @@ module Issues
include SpamCheckService
def execute(issue)
handle_move_between_iids(issue)
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update(issue)
......@@ -54,13 +54,13 @@ module Issues
end
end
def handle_move_between_iids(issue)
return unless params[:move_between_iids]
def handle_move_between_ids(issue)
return unless params[:move_between_ids]
after_iid, before_iid = params.delete(:move_between_iids)
after_id, before_id = params.delete(:move_between_ids)
issue_before = get_issue_if_allowed(issue.project, before_iid) if before_iid
issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid
issue_before = get_issue_if_allowed(before_id) if before_id
issue_after = get_issue_if_allowed(after_id) if after_id
issue.move_between(issue_before, issue_after)
end
......@@ -87,8 +87,8 @@ module Issues
private
def get_issue_if_allowed(project, iid)
issue = project.issues.find_by(iid: iid)
def get_issue_if_allowed(id)
issue = Issue.find(id)
issue if can?(current_user, :update_issue, issue)
end
......
......@@ -8,6 +8,12 @@
%span
List
- if @group.feature_available?(:group_issue_boards)
= nav_link(path: 'groups#boards') do
= link_to group_boards_path(@group), title: 'Boards' do
%span
Boards
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do
%span
......
= render "shared/boards/show", board: @boards.first
= render "shared/boards/show", board: @board
- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index']
- if @group.feature_available?(:group_issue_boards)
- issues_sub_menu_items.push('boards#index')
.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
......@@ -39,7 +42,7 @@
%span
Contribution Analytics
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
= nav_link(path: issues_sub_menu_items) do
= sidebar_link issues_group_path(@group), title: _('Issues') do
.nav-icon-container
= custom_icon('issues')
......@@ -59,6 +62,12 @@
%span
List
- if @group.feature_available?(:group_issue_boards)
= nav_link(path: 'boards#index') do
= link_to group_boards_path(@group), title: 'Boards' do
%span
Boards
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do
%span
......
= render "show", board: @boards.first
= render "shared/boards/show", board: @boards.first
= render "show", board: @board
= render "shared/boards/show", board: @board
......@@ -5,16 +5,21 @@
- breadcrumb_title "Issue Boards"
- page_title "Boards"
- add_to_breadcrumbs("Issues", @issues_path)
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
= webpack_bundle_tag 'boards'
%script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board"
%script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
%script#js-board-promotion{ type: "text/x-template" }= render "shared/promotions/promote_issue_board"
= render "projects/issues/head"
- if @group
= render "groups/head_issues"
- else
= render "projects/issues/head"
.hidden-xs.hidden-sm
= render 'shared/issuable/search_bar', type: :boards, board: board
......@@ -32,11 +37,12 @@
":root-path" => "rootPath",
":board-id" => "boardId",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
"label-path" => labels_filter_path,
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":project-id" => @project.try(:id) }
= render "shared/boards/components/sidebar"
- if @project
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
"label-path" => labels_filter_path,
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":project-id" => @project.try(:id) }
- parent = board.parent
- milestone_filter_opts = { format: :json }
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
%boards-selector{ "inline-template" => true,
":current-board" => current_board_json,
"milestone-path" => project_milestones_path(board.project, :json) }
"milestone-path" => milestones_filter_path(milestone_filter_opts) }
.dropdown
%button.dropdown-menu-toggle{ "@click" => "loadBoards",
data: { toggle: "dropdown" } }
......@@ -20,40 +24,40 @@
.dropdown-content{ "v-if" => "currentPage === ''" }
%ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{project_boards_path(@project)}/' + board.id" }
%a{ ":href" => "'#{board_base_url}/' + board.id" }
{{ board.name }}
- if !@project.feature_available?(:multiple_issue_boards) && @project.boards.size > 1
- 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, @project)
- 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 "projects/boards/components/form"
= 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 project_board_path(@project, board),
= link_to current_board_path(board),
class: "btn btn-danger pull-left",
method: :delete do
Delete
%button.btn.btn-default.pull-right{ type: "button",
"@click.stop.prevent" => "showPage('')" }
Cancel
- if can?(current_user, :admin_board, @project)
- if can?(current_user, :admin_board, parent)
.dropdown-footer{ "v-if" => "currentPage === ''" }
%ul.dropdown-footer-list
- if @project.feature_available?(:multiple_issue_boards)
- if parent.feature_available?(:multiple_issue_boards)
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('new')" }
Create new board
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name
- if @project.feature_available?(:issue_board_milestone, current_user)
- if parent.issue_board_milestone_available?(current_user)
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" }
Edit board milestone
......
......@@ -7,20 +7,26 @@
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
"aria-hidden": "true" }
%span.has-tooltip{ "v-if": "list.type !== \"label\"",
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
":title" => '(list.label ? list.label.description : "")' }
{{ list.title }}
%span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" },
class: "label color-label title",
class: " label color-label title board-title-text",
":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" }
{{ list.title }}
.issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
- if can?(current_user, :admin_list, current_board_parent)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.append-right-10{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
%span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
- if can?(current_user, :admin_list, current_board_parent)
%button.issue-count-badge-add-button.btn.btn-small.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
......@@ -28,12 +34,6 @@
"title" => "New issue",
data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list",
":issues" => "list.issues",
......@@ -41,7 +41,8 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":groupId" => ((current_board_parent.id if @group) || 'null'),
"ref" => "board-list" }
- if can?(current_user, :admin_list, @project)
- if can?(current_user, :admin_list, current_board_parent)
%board-blank-state{ "v-if" => 'list.id == "blank"' }
%board-promotion-state{ "v-if" => 'list.id == "promotion"' }
......@@ -10,7 +10,7 @@
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
- if @project.feature_available?(:issue_board_milestone, current_user)
- 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" }
......
......@@ -10,18 +10,19 @@
%br/
%span
= precede "#" do
{{ issue.id }}
{{ issue.iid }}
%a.gutter-toggle.pull-right{ role: "button",
href: "#",
"@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" }
= custom_icon("icon_close", size: 15)
.js-issuable-update
= render "projects/boards/components/sidebar/assignee"
= render "projects/boards/components/sidebar/milestone"
= render "projects/boards/components/sidebar/due_date"
= render "projects/boards/components/sidebar/labels"
= render "projects/boards/components/sidebar/notifications"
= render "shared/boards/components/sidebar/assignee"
= render "shared/boards/components/sidebar/milestone"
= render "shared/boards/components/sidebar/due_date"
= render "shared/boards/components/sidebar/labels"
= render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
":list" => "list",
"v-if" => "canRemove" }
......@@ -2,13 +2,13 @@
%template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length",
":loading" => "loadingAssignees",
":editable" => can?(current_user, :admin_issue, @project) }
":editable" => can_admin_issue? }
%assignees.value{ "root-path" => "#{root_url}",
":users" => "issue.assignees",
":editable" => can?(current_user, :admin_issue, @project),
":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" }
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox.hide-collapsed
%input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]",
......@@ -20,9 +20,9 @@
":data-username" => "assignee.username" }
.dropdown
- dropdown_options = issue_assignees_dropdown_options
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: { toggle: 'dropdown', field_name: 'issue[assignee_ids][]', first_user: current_user&.username, current_user: 'true', project_id: @project.id, null_user: 'true', multi_select: 'true', 'dropdown-header': dropdown_options[:data][:'dropdown-header'], 'max-select': dropdown_options[:data][:'max-select'] },
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
= dropdown_options[:title]
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
......
.block.due_date
.title
Due date
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
......@@ -10,12 +10,12 @@
No due date
%span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }}
- if can?(current_user, :admin_issue, @project)
- if can?(current_user, :admin_issue, current_board_parent)
%span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
remove due date
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
name: "issue[due_date]",
......@@ -23,7 +23,7 @@
.dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
......
.block.labels
.title
Labels
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels
......@@ -11,7 +11,7 @@
"v-for" => "label in issue.labels" }
%span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
{{ label.title }}
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
name: "issue[label_names][]",
......@@ -19,12 +19,19 @@
":value" => "label.id" }
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: project_labels_path(@project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) },
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
data: { toggle: "dropdown",
field_name: "issue[label_names][]",
show_no: "true",
show_any: "true",
project_id: @project&.try(:id),
labels: labels_filter_path(false),
namespace_path: @project.try(:namespace).try(:full_path),
project_path: @project.try(:path) },
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default"
- if can? current_user, :admin_label, @project and @project
- if can?(current_user, :admin_label, current_board_parent)
= render partial: "shared/issuable/label_page_create"
.block.milestone
.title
Milestone
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
......@@ -9,17 +9,17 @@
None
%span.bold.has-tooltip{ "v-if" => "issue.milestone" }
{{ issue.milestone.title }}
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
":value" => "issue.milestone.id",
name: "issue[milestone_id]",
"v-if" => "issue.milestone" }
.dropdown
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), ability_name: "issue", use_id: "true", default_no: "true" },
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
":data-selected" => "milestoneTitle",
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
......
- if current_user
.block.light.subscription{ ":data-url" => "'#{project_issues_path(@project)}/' + issue.id + '/toggle_subscription'" }
.block.light.subscription{ ":data-url" => "'#{build_issue_link_base}/' + issue.iid + '/toggle_subscription'" }
%span.issuable-header-text.hide-collapsed.pull-left
Notifications
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
......
......@@ -8,20 +8,19 @@
- if show_boards_content
.issue-board-dropdown-content
%p
Create lists from the labels you use in your project. Issues with that
label will automatically be added to the list.
Create lists from labels. Issues with that label appear in that list.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
- if current_board_parent && show_footer
= dropdown_footer do
%ul.dropdown-footer-list
- if can?(current_user, :admin_label, @project)
- if can?(current_user, :admin_label, current_board_parent)
%li
%a.dropdown-toggle-page{ href: "#" }
Create new label
%li
= link_to project_labels_path(@project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
= link_to labels_path, :"data-is-link" => true do
- if show_create && can?(current_user, :admin_label, current_board_parent)
Manage labels
- else
View labels
......
......@@ -7,7 +7,7 @@
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards && board
#js-multiple-boards-switcher.inline.boards-switcher{ "v-cloak" => true }
= render "projects/boards/switcher", board: board
= render "shared/boards/switcher", board: board
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
......@@ -124,15 +124,19 @@
= icon('times')
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, @project)
- if can?(current_user, :admin_list, board.parent)
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) } }
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, @project)
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } }
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } }
#js-toggle-focus-btn.prepend-left-10
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
---
title: Add group issue boards
merge_request:
author:
......@@ -89,6 +89,19 @@ Rails.application.routes.draw do
# Notification settings
resources :notification_settings, only: [:create, :update]
# Boards resources shared between group and projects
resources :boards do
resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
collection do
post :generate
end
resources :issues, only: [:index, :create, :update]
end
resources :issues, module: :boards, only: [:index, :update]
end
draw :import
draw :uploads
draw :explore
......
......@@ -62,6 +62,9 @@ scope(path: 'groups/*group_id',
resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :billings, only: [:index]
end
## EE-specific
resources :boards, only: [:index, :show, :create, :update, :destroy]
end
scope(path: 'groups/*id',
......
......@@ -381,19 +381,7 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
resources :boards, only: [:index, :show, :create, :update, :destroy] do
scope module: :boards do
resources :issues, only: [:index, :update]
resources :lists, only: [:index, :create, :update, :destroy] do
collection do
post :generate
end
resources :issues, only: [:index, :create]
end
end
end
resources :boards, only: [:index, :show, :create, :update, :destroy]
resources :todos, only: [:create]
......
class AddGroupIdToBoards < ActiveRecord::Migration
DOWNTIME = false
def up
change_column_null :boards, :project_id, true
add_column :boards, :group_id, :integer
end
def down
# We cannot rollback project_id not null constraint if there are records
# with null values.
execute "DELETE from boards WHERE project_id IS NULL"
remove_column :boards, :group_id
change_column :boards, :project_id, :integer, null: false
end
end
class AddGroupBoardsIndexes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_concurrent_foreign_key :boards, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :boards, :group_id
end
def down
remove_foreign_key :boards, column: :group_id
remove_concurrent_index :boards, :group_id
end
end
......@@ -214,13 +214,15 @@ ActiveRecord::Schema.define(version: 20170906160132) do
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
create_table "boards", force: :cascade do |t|
t.integer "project_id", null: false
t.integer "project_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name", default: "Development", null: false
t.integer "milestone_id"
t.integer "group_id"
end
add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
add_index "boards", ["milestone_id"], name: "index_boards_on_milestone_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
......@@ -2036,6 +2038,7 @@ ActiveRecord::Schema.define(version: 20170906160132) do
add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", name: "fk_1e9a074a35", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
......
......@@ -93,7 +93,8 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [Discussions](user/discussions/index.md) Threads, comments, and resolvable discussions in issues, commits, and merge requests.
- [Issues](user/project/issues/index.md)
- [Issue Board](user/project/issue_board.md)
- [Project issue Board](user/project/issue_board.md)
- **(EEP)** [Group issue boards](user/group/issue_boards.md)
- **(EES/EEP)** [Related Issues](user/project/issues/related_issues.md): create a relationship between issues
- [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests.
- [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles.
......
# Group issue board
> Introduced in GitLab 10.0.
Group issue boards help users manage teams in organizations
that have a team-centered or product-centered approach which spans
many projects.
![Group Issue Board](img/group_issue_board.png)
The design and functionality are the same as [project boards](../project/issue_board.md) with some small differences:
* In a group board all issues within group projects will be displayed
on backlog or closed lists. Users don't need to filter them by project on search bar.
* Group boards can only have [group milestones](../project/milestones/index.md#creating-a-group-milestone) associated with them.
* Only group [labels](../project/labels.md) can be used to create lists inside each board.
>**Note:**
Only issues in immediate child projects of the group are available in the group board. Issues in further descendant subgroups are not shown in the group board.
module EE
module Projects
module Boards
module BoardsController
extend ActiveSupport::Concern
prepended do
before_action :check_multiple_issue_boards_available!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy]
......@@ -9,7 +10,7 @@ module EE
end
def create
board = ::Boards::CreateService.new(project, current_user, board_params).execute
board = ::Boards::CreateService.new(parent, current_user, board_params).execute
respond_to do |format|
format.json do
......@@ -23,7 +24,7 @@ module EE
end
def update
service = ::Boards::UpdateService.new(project, current_user, board_params)
service = ::Boards::UpdateService.new(parent, current_user, board_params)
service.execute(@board)
......@@ -39,18 +40,18 @@ module EE
end
def destroy
service = ::Boards::DestroyService.new(project, current_user)
service = ::Boards::DestroyService.new(parent, current_user)
service.execute(@board)
respond_to do |format|
format.html { redirect_to project_boards_path(@project), status: 302 }
format.html { redirect_to boards_path, status: 302 }
end
end
private
def authorize_admin_board!
return render_404 unless can?(current_user, :admin_board, project)
return render_404 unless can?(current_user, :admin_board, parent)
end
def board_params
......@@ -58,7 +59,19 @@ module EE
end
def find_board
@board = project.boards.find(params[:id])
@board = parent.boards.find(params[:id])
end
def parent
@parent ||= @project || @group
end
def boards_path
if @group
group_boards_path(parent)
else
project_boards_path(parent)
end
end
def serialize_as_json(resource)
......
module EE
module Boards
module IssuesController
def issues_finder
return super unless board.group_board?
IssuesFinder.new(current_user, group_id: board_parent.id)
end
def project
@project ||= begin
if board.group_board?
::Project.find(issue_params[:project_id])
else
super
end
end
end
end
end
end
module EE
module BoardsResponses
# Shared authorizations between projects and groups which
# have different policies on EE.
def authorize_read_list
ability = board.group_board? ? :read_group : :read_list
authorize_action_for!(board.parent, ability)
end
def authorize_read_issue
ability = board.group_board? ? :read_group : :read_issue
authorize_action_for!(board.parent, ability)
end
end
end
class Groups::BoardsController < Groups::ApplicationController
prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses
before_action :check_group_issue_boards_available!
before_action :assign_endpoint_vars
def index
@boards = Boards::ListService.new(group, current_user).execute
respond_with_boards
end
def show
@board = group.boards.find(params[:id])
respond_with_board
end
def assign_endpoint_vars
@boards_endpoint = group_boards_url(group)
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
end
module EE
module BoardsHelper
def parent
@group || @project
end
def board_data
super.merge(focus_mode_available: @project.feature_available?(:issue_board_focus_mode).to_s,
show_promotion: (show_promotions? && (!@project.feature_available?(:multiple_issue_boards) || !@project.feature_available?(:issue_board_milestone) || !@project.feature_available?(:issue_board_focus_mode))).to_s)
data = {
board_milestone_title: board&.milestone&.title,
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
}
super.merge(data)
end
def build_issue_link_base
return super unless @board.group_board?
"/#{@board.group.path}/:project_path/issues"
end
def board_base_url
if board.group_board?
group_boards_url(@group)
else
super
end
end
def current_board_path(board)
@current_board_path ||= begin
if board.group_board?
group_board_path(current_board_parent, board)
else
super(board)
end
end
end
def current_board_parent
@current_board_parent ||= @group || super
end
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def board_list_data
super.merge(group_path: @group&.path)
end
def board_sidebar_user_data
super.merge(group_id: @group&.id)
end
end
end
......@@ -3,7 +3,7 @@ module EE
def issue_assignees_dropdown_options
options = super
if @project.feature_available?(:multiple_issue_assignees)
if current_board_parent.feature_available?(:multiple_issue_assignees)
options[:title] = 'Select assignee(s)'
options[:data][:'dropdown-header'] = 'Assignee(s)'
options[:data].delete(:'max-select')
......
module EE
# Issue position on list boards should be relative to all group projects
module RelativePositioning
extend ActiveSupport::Concern
def board_group
@group ||= project.group
end
def has_group_boards?
board_group && board_group.boards.any?
end
def project_ids
return super unless has_group_boards?
board_group.projects.select(:id)
end
end
end
......@@ -4,10 +4,17 @@ module EE
prepended do
belongs_to :milestone
belongs_to :group
validates :group, presence: true, unless: :project
end
def project_needed?
!group
end
def milestone
return nil unless project.feature_available?(:issue_board_milestone)
return nil unless parent.feature_available?(:issue_board_milestone)
if milestone_id == ::Milestone::Upcoming.id
::Milestone::Upcoming
......@@ -16,6 +23,14 @@ module EE
end
end
def parent
@parent ||= group || project
end
def group_board?
group_id.present?
end
def as_json(options = {})
milestone_attrs = options.fetch(:include, {})
.extract!(:milestone)
......
......@@ -7,6 +7,8 @@ module EE
extend ActiveSupport::Concern
included do
has_many :boards
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
state :ready
state :started
......
module EE
module Label
extend ActiveSupport::Concern
prepended do
scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
end
end
end
......@@ -6,6 +6,12 @@ module EE
with_scope :subject
condition(:ldap_synced) { @subject.ldap_synced? }
rule { reporter }.policy do
enable :admin_list
enable :admin_board
enable :admin_issue
end
condition(:can_owners_manage_ldap, scope: :global) do
::Gitlab::CurrentSettings.current_application_settings
.allow_group_owners_to_manage_ldap
......
......@@ -4,7 +4,7 @@ module EE
def can_create_board?
raise NotImplementedError unless defined?(super)
project.feature_available?(:multiple_issue_boards) || super
parent.feature_available?(:multiple_issue_boards) || super
end
end
end
......
module EE
module Boards
module Issues
module ListService
def set_parent
if @parent.is_a?(Group)
params[:group_id] = @parent.id
else
super
end
end
end
end
end
end
......@@ -4,7 +4,7 @@ module EE
def execute
raise NotImplementedError unless defined?(super)
if project.feature_available?(:multiple_issue_boards, current_user)
if parent.multiple_issue_boards_available?(current_user)
super
else
super.limit(1)
......
module EE
module Boards
module Lists
module CreateService
def available_labels_for(board)
if board.group_board?
parent.labels
else
super
end
end
end
end
end
end
module EE
module Boards
module MoveService
def remove_label_ids
label_ids =
if moving_to_list.movable?
moving_from_list.label_id
elsif board.group_board?
::Label.on_group_boards(parent.id).pluck(:label_id)
else
::Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
end
end
end
end
- return unless @project
.board-promotion-state
.svg-container.center
= custom_icon('icon_issue_board')
......
......@@ -26,6 +26,7 @@ module Gitlab
apple-touch-icon.png
assets
autocomplete
boards
ci
dashboard
deploy.html
......@@ -118,6 +119,7 @@ module Gitlab
analytics
audit_events
avatar
boards
edit
group_members
hooks
......
require 'spec_helper'
describe Projects::Boards::IssuesController do
describe Boards::IssuesController do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
......@@ -162,17 +162,15 @@ describe Projects::Boards::IssuesController do
def create_issue(user:, board:, list:, title:)
sign_in(user)
post :create, namespace_id: project.namespace.to_param,
project_id: project,
board_id: board.to_param,
post :create, board_id: board.to_param,
list_id: list.to_param,
issue: { title: title },
issue: { title: title, project_id: project.id },
format: :json
end
end
describe 'PATCH update' do
let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
let!(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
context 'with valid params' do
it 'returns a successful 200 response' do
......@@ -202,7 +200,7 @@ describe Projects::Boards::IssuesController do
end
it 'returns a not found 404 response for invalid issue id' do
move user: user, board: board, issue: 999, from_list_id: list1.id, to_list_id: list2.id
move user: user, board: board, issue: double(id: 999), from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404)
end
......@@ -226,9 +224,9 @@ describe Projects::Boards::IssuesController do
sign_in(user)
patch :update, namespace_id: project.namespace.to_param,
project_id: project,
project_id: project.id,
board_id: board.to_param,
id: issue.to_param,
id: issue.id,
from_list_id: from_list_id,
to_list_id: to_list_id,
format: :json
......
require 'spec_helper'
describe Projects::Boards::ListsController do
describe Boards::ListsController do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
......
require 'spec_helper'
describe Boards::IssuesController do
let(:group) { create(:group) }
let(:project_1) { create(:project, namespace: group) }
let(:project_2) { create(:project, namespace: group) }
let(:board) { create(:board, group: group) }
let(:user) { create(:user) }
let(:guest) { create(:user) }
let(:planning) { create(:group_label, group: group, name: 'Planning') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: development, position: 1) }
before do
group.add_master(user)
group.add_guest(guest)
end
describe 'GET index' do
let(:johndoe) { create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) }
context 'with invalid board id' do
it 'returns a not found 404 response' do
list_issues user: user, board: 999, list: list2
expect(response).to have_http_status(404)
end
end
context 'when list id is present' do
context 'with valid list id' do
it 'returns issues that have the list label applied' do
issue = create(:labeled_issue, project: project_1, labels: [planning])
create(:labeled_issue, project: project_1, labels: [planning])
create(:labeled_issue, project: project_2, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project_2, labels: [development], assignees: [johndoe])
issue.subscribe(johndoe, project_1)
list_issues user: user, board: board, list: list2
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2
expect(development.issues.map(&:relative_position)).not_to include(nil)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
list_issues user: user, board: board, list: 999
expect(response).to have_http_status(404)
end
end
end
context 'when list id is missing' do
it 'returns opened issues without board labels applied' do
bug = create(:label, project: project_1, name: 'Bug')
create(:issue, project: project_1)
create(:labeled_issue, project: project_2, labels: [planning])
create(:labeled_issue, project: project_2, labels: [development])
create(:labeled_issue, project: project_2, labels: [bug])
list_issues user: user, board: board
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2
end
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a forbidden 403 response' do
list_issues user: user, board: board, list: list2
expect(response).to have_http_status(403)
end
end
def list_issues(user:, board:, list: nil)
sign_in(user)
params = {
board_id: board.to_param,
list_id: list.try(:to_param)
}
get :index, params.compact
end
end
describe 'POST create' do
context 'with valid params' do
it 'returns a successful 200 response' do
create_issue user: user, board: board, list: list1, title: 'New issue'
expect(response).to have_http_status(200)
end
it 'returns the created issue' do
create_issue user: user, board: board, list: list1, title: 'New issue'
expect(response).to match_response_schema('issue')
end
end
context 'with invalid params' do
context 'when title is nil' do
it 'returns an unprocessable entity 422 response' do
create_issue user: user, board: board, list: list1, title: nil
expect(response).to have_http_status(422)
end
end
context 'when list does not belongs to project board' do
it 'returns a not found 404 response' do
list = create(:list)
create_issue user: user, board: board, list: list, title: 'New issue'
expect(response).to have_http_status(404)
end
end
context 'with invalid board id' do
it 'returns a not found 404 response' do
create_issue user: user, board: 999, list: list1, title: 'New issue'
expect(response).to have_http_status(404)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
create_issue user: user, board: board, list: 999, title: 'New issue'
expect(response).to have_http_status(404)
end
end
end
context 'with unauthorized user' do
it 'returns a forbidden 403 response' do
create_issue user: guest, board: board, list: list1, title: 'New issue'
expect(response).to have_http_status(403)
end
end
def create_issue(user:, board:, list:, title:)
sign_in(user)
post :create, board_id: board.to_param,
list_id: list.to_param,
issue: { title: title, project_id: project_1.id },
format: :json
end
end
describe 'PATCH update' do
let!(:issue) { create(:labeled_issue, project: project_1, labels: [planning]) }
context 'with valid params' do
it 'returns a successful 200 response' do
move user: user, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(200)
end
it 'moves issue to the desired list' do
move user: user, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(issue.reload.labels).to contain_exactly(development)
end
end
context 'with invalid params' do
it 'returns a unprocessable entity 422 response for invalid lists' do
move user: user, board: board, issue: issue, from_list_id: nil, to_list_id: nil
expect(response).to have_http_status(422)
end
it 'returns a not found 404 response for invalid board id' do
move user: user, board: 999, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404)
end
it 'returns a not found 404 response for invalid issue id' do
move user: user, board: board, issue: double(id: 999), from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
let(:guest) { create(:user) }
before do
group.add_guest(guest)
end
it 'returns a forbidden 403 response' do
move user: guest, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(403)
end
end
def move(user:, board:, issue:, from_list_id:, to_list_id:)
sign_in(user)
patch :update, board_id: board.to_param,
id: issue.id,
from_list_id: from_list_id,
to_list_id: to_list_id,
format: :json
end
end
end
require 'spec_helper'
describe Boards::ListsController do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:user) { create(:user) }
let(:guest) { create(:user) }
before do
group.add_master(user)
group.add_guest(guest)
end
describe 'GET index' do
it 'returns a successful 200 response' do
read_board_list user: user, board: board
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/json'
end
it 'returns a list of board lists' do
create(:list, board: board)
read_board_list user: user, board: board
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists')
expect(parsed_response.length).to eq 3
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a forbidden 403 response' do
read_board_list user: user, board: board
expect(response).to have_http_status(403)
end
end
def read_board_list(user:, board:)
sign_in(user)
get :index, board_id: board.to_param, format: :json
end
end
describe 'POST create' do
context 'with valid params' do
let(:label) { create(:group_label, group: group, name: 'Development') }
it 'returns a successful 200 response' do
create_board_list user: user, board: board, label_id: label.id
expect(response).to have_http_status(200)
end
it 'returns the created list' do
create_board_list user: user, board: board, label_id: label.id
expect(response).to match_response_schema('list')
end
end
context 'with invalid params' do
context 'when label is nil' do
it 'returns a not found 404 response' do
create_board_list user: user, board: board, label_id: nil
expect(response).to have_http_status(404)
end
end
context 'when label that does not belongs to group' do
it 'returns a not found 404 response' do
label = create(:label, name: 'Development')
create_board_list user: user, board: board, label_id: label.id
expect(response).to have_http_status(404)
end
end
end
context 'with unauthorized user' do
it 'returns a forbidden 403 response' do
label = create(:group_label, group: group, name: 'Development')
create_board_list user: guest, board: board, label_id: label.id
expect(response).to have_http_status(403)
end
end
def create_board_list(user:, board:, label_id:)
sign_in(user)
post :create, board_id: board.to_param,
list: { label_id: label_id },
format: :json
end
end
describe 'PATCH update' do
let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) }
context 'with valid position' do
it 'returns a successful 200 response' do
move user: user, board: board, list: planning, position: 1
expect(response).to have_http_status(200)
end
it 'moves the list to the desired position' do
move user: user, board: board, list: planning, position: 1
expect(planning.reload.position).to eq 1
end
end
context 'with invalid position' do
it 'returns an unprocessable entity 422 response' do
move user: user, board: board, list: planning, position: 6
expect(response).to have_http_status(422)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
move user: user, board: board, list: 999, position: 1
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
it 'returns a forbidden 403 response' do
move user: guest, board: board, list: planning, position: 6
expect(response).to have_http_status(403)
end
end
def move(user:, board:, list:, position:)
sign_in(user)
patch :update, board_id: board.to_param,
id: list.to_param,
list: { position: position },
format: :json
end
end
describe 'DELETE destroy' do
let!(:planning) { create(:list, board: board, position: 0) }
context 'with valid list id' do
it 'returns a successful 200 response' do
remove_board_list user: user, board: board, list: planning
expect(response).to have_http_status(200)
end
it 'removes list from board' do
expect { remove_board_list user: user, board: board, list: planning }.to change(board.lists, :size).by(-1)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
remove_board_list user: user, board: board, list: 999
expect(response).to have_http_status(404)
end
end
context 'with unauthorized user' do
it 'returns a forbidden 403 response' do
remove_board_list user: guest, board: board, list: planning
expect(response).to have_http_status(403)
end
end
def remove_board_list(user:, board:, list:)
sign_in(user)
delete :destroy, board_id: board.to_param,
id: list.to_param,
format: :json
end
end
end
require 'spec_helper'
describe Groups::BoardsController do
let(:group) { create(:group) }
let(:user) { create(:user) }
before do
group.add_master(user)
sign_in(user)
stub_licensed_features(group_issue_boards: true)
end
describe 'GET index' do
it 'creates a new board when group does not have one' do
expect { list_boards }.to change(group.boards, :count).by(1)
end
context 'when format is HTML' do
it 'renders template' do
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
list_boards
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'returns a list of group boards' do
create(:board, group: group, milestone: create(:milestone, group: group))
create(:board, group: group, milestone_id: Milestone::Upcoming.id)
list_boards format: :json
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('boards')
expect(parsed_response.length).to eq 2
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
list_boards format: :json
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end
end
def list_boards(format: :html)
get :index, group_id: group, format: format
end
end
describe 'GET show' do
let!(:board) { create(:board, group: group) }
context 'when format is HTML' do
it 'renders template' do
read_board board: board
expect(response).to render_template :show
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'returns project board' do
read_board board: board, format: :json
expect(response).to match_response_schema('board')
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board, format: :json
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end
end
context 'when board does not belong to group' do
it 'returns a not found 404 response' do
another_board = create(:board)
read_board board: another_board
expect(response).to have_http_status(404)
end
end
def read_board(board:, format: :html)
get :show, group_id: group,
id: board.to_param,
format: format
end
end
end
require 'spec_helper'
describe Board do
context 'validations' do
context 'when group is present' do
subject { described_class.new(group: create(:group)) }
it { is_expected.not_to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:group) }
end
context 'when project is present' do
subject { described_class.new(project: create(:project)) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.not_to validate_presence_of(:group) }
end
end
describe 'milestone' do
subject(:board) { build(:board) }
......
require 'spec_helper'
describe EE::RelativePositioning do
let(:group) { create(:group) }
let!(:board) { create(:board, group: group) }
let(:project) { create(:project, namespace: group) }
let(:project1) { create(:project, namespace: group) }
let(:issue) { create(:issue, project: project) }
let(:issue1) { create(:issue, project: project1) }
let(:new_issue) { create(:issue, project: project1) }
before do
[issue, issue1].each do |issue|
issue.move_to_end && issue.save
end
end
describe '#max_relative_position' do
it 'returns maximum position' do
expect(issue.max_relative_position).to eq issue1.relative_position
end
end
describe '#prev_relative_position' do
it 'returns previous position if there is an issue above' do
expect(issue1.prev_relative_position).to eq issue.relative_position
end
it 'returns nil if there is no issue above' do
expect(issue.prev_relative_position).to eq nil
end
end
describe '#next_relative_position' do
it 'returns next position if there is an issue below' do
expect(issue.next_relative_position).to eq issue1.relative_position
end
it 'returns nil if there is no issue below' do
expect(issue1.next_relative_position).to eq nil
end
end
describe '#move_before' do
it 'moves issue before' do
[issue1, issue].each(&:move_to_end)
issue.move_before(issue1)
expect(issue.relative_position).to be < issue1.relative_position
end
end
describe '#move_after' do
it 'moves issue after' do
[issue, issue1].each(&:move_to_end)
issue.move_after(issue1)
expect(issue.relative_position).to be > issue1.relative_position
end
end
describe '#move_to_end' do
it 'moves issue to the end' do
new_issue.move_to_end
expect(new_issue.relative_position).to be > issue1.relative_position
end
end
describe '#shift_after?' do
it 'returns true' do
issue.update(relative_position: issue1.relative_position - 1)
expect(issue.shift_after?).to be_truthy
end
it 'returns false' do
issue.update(relative_position: issue1.relative_position - 2)
expect(issue.shift_after?).to be_falsey
end
end
describe '#shift_before?' do
it 'returns true' do
issue.update(relative_position: issue1.relative_position + 1)
expect(issue.shift_before?).to be_truthy
end
it 'returns false' do
issue.update(relative_position: issue1.relative_position + 2)
expect(issue.shift_before?).to be_falsey
end
end
describe '#move_between' do
it 'positions issue between two other' do
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be > issue.relative_position
expect(new_issue.relative_position).to be < issue1.relative_position
end
it 'positions issue between on top' do
new_issue.move_between(nil, issue)
expect(new_issue.relative_position).to be < issue.relative_position
end
it 'positions issue between to end' do
new_issue.move_between(issue1, nil)
expect(new_issue.relative_position).to be > issue1.relative_position
end
it 'positions issues even when after and before positions are the same' do
issue1.update relative_position: issue.relative_position
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be > issue.relative_position
expect(issue.relative_position).to be < issue1.relative_position
end
it 'positions issues between other two if distance is 1' do
issue1.update relative_position: issue.relative_position + 1
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be > issue.relative_position
expect(issue.relative_position).to be < issue1.relative_position
end
it 'positions issue in the middle of other two if distance is big enough' do
issue.update relative_position: 6000
issue1.update relative_position: 10000
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to eq(8000)
end
it 'positions issue closer to the middle if we are at the very top' do
issue1.update relative_position: 6000
new_issue.move_between(nil, issue1)
expect(new_issue.relative_position).to eq(6000 - RelativePositioning::IDEAL_DISTANCE)
end
it 'positions issue closer to the middle if we are at the very bottom' do
issue.update relative_position: 6000
issue1.update relative_position: nil
new_issue.move_between(issue, nil)
expect(new_issue.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE)
end
it 'positions issue in the middle of other two if distance is not big enough' do
issue.update relative_position: 100
issue1.update relative_position: 400
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to eq(250)
end
it 'positions issue in the middle of other two is there is no place' do
issue.update relative_position: 100
issue1.update relative_position: 101
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to be_between(issue.relative_position, issue1.relative_position)
end
it 'uses rebalancing if there is no place' do
issue.update relative_position: 100
issue1.update relative_position: 101
issue2 = create(:issue, relative_position: 102, project: project)
new_issue.update relative_position: 103
new_issue.move_between(issue1, issue2)
new_issue.save!
expect(new_issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
expect(issue.reload.relative_position).not_to eq(100)
end
it 'positions issue right if we pass none-sequential parameters' do
issue.update relative_position: 99
issue1.update relative_position: 101
issue2 = create(:issue, relative_position: 102, project: project)
new_issue.update relative_position: 103
new_issue.move_between(issue, issue2)
new_issue.save!
expect(new_issue.relative_position).to be(100)
end
end
end
require 'spec_helper'
describe Boards::CreateService do
describe '#execute' do
let(:project) { create(:project) }
describe Boards::CreateService, services: true do
shared_examples 'boards create service' do
context 'With the feature available' do
before do
stub_licensed_features(multiple_issue_boards: true)
end
context 'with valid params' do
subject(:service) { described_class.new(project, double, name: 'Backend') }
subject(:service) { described_class.new(parent, double, name: 'Backend') }
it 'creates a new project board' do
expect { service.execute }.to change(project.boards, :count).by(1)
it 'creates a new board' do
expect { service.execute }.to change(parent.boards, :count).by(1)
end
it 'creates the default lists' do
......@@ -26,10 +24,10 @@ describe Boards::CreateService do
end
context 'with invalid params' do
subject(:service) { described_class.new(project, double, name: nil) }
subject(:service) { described_class.new(parent, double, name: nil) }
it 'does not create a new project board' do
expect { service.execute }.not_to change(project.boards, :count)
it 'does not create a new parent board' do
expect { service.execute }.not_to change(parent.boards, :count)
end
it "does not create board's default lists" do
......@@ -40,10 +38,10 @@ describe Boards::CreateService do
end
context 'without params' do
subject(:service) { described_class.new(project, double) }
subject(:service) { described_class.new(parent, double) }
it 'creates a new project board' do
expect { service.execute }.to change(project.boards, :count).by(1)
it 'creates a new parent board' do
expect { service.execute }.to change(parent.boards, :count).by(1)
end
it "creates board's default lists" do
......@@ -57,11 +55,21 @@ describe Boards::CreateService do
it 'skips creating a second board when the feature is not available' do
stub_licensed_features(multiple_issue_boards: false)
service = described_class.new(project, double)
service = described_class.new(parent, double)
expect(service.execute).not_to be_nil
expect { service.execute }.not_to change(project.boards, :count)
expect { service.execute }.not_to change(parent.boards, :count)
end
end
describe '#execute' do
it_behaves_like 'boards create service' do
let(:parent) { create(:project, :empty_repo) }
end
it_behaves_like 'boards create service' do
let(:parent) { create(:group) }
end
end
end
require 'spec_helper'
describe Boards::DestroyService, services: true do
describe '#execute' do
let(:group) { create(:group) }
let!(:board) { create(:board, group: group) }
subject(:service) { described_class.new(group, double) }
context 'when group have more than one board' do
it 'removes board from group' do
create(:board, group: group)
expect { service.execute(board) }.to change(group.boards, :count).by(-1)
end
end
context 'when group have one board' do
it 'does not remove board from group' do
expect { service.execute(board) }.not_to change(group.boards, :count)
end
end
end
end
require 'spec_helper'
describe Boards::Issues::ListService, services: true do
describe '#execute' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :empty_repo, namespace: group) }
let(:project1) { create(:project, :empty_repo, namespace: group) }
let(:board) { create(:board, group: group) }
let(:m1) { create(:milestone, group: group) }
let(:m2) { create(:milestone, group: group) }
let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let(:testing) { create(:group_label, group: group, name: 'Testing') }
let(:p1) { create(:group_label, title: 'P1', group: group) }
let(:p2) { create(:group_label, title: 'P2', group: group) }
let(:p3) { create(:group_label, title: 'P3', group: group) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) }
let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Issue 3', closed_at: Time.now ) }
let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
let!(:list1_issue3) { create(:labeled_issue, project: project1, milestone: m1, labels: [development, p1]) }
let!(:list2_issue1) { create(:labeled_issue, project: project1, milestone: m1, labels: [testing]) }
let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
let!(:closed_issue3) { create(:issue, :closed, project: project1) }
let!(:closed_issue4) { create(:labeled_issue, :closed, project: project1, labels: [p1]) }
let!(:closed_issue5) { create(:labeled_issue, :closed, project: project1, labels: [development]) }
before do
group.add_developer(user)
end
it 'delegates search to IssuesFinder' do
params = { board_id: board.id, id: list1.id }
expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
described_class.new(group, user, params).execute
end
context 'when list_id is missing' do
context 'when board does not have a milestone' do
it 'returns opened issues without board labels applied' do
params = { board_id: board.id }
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([opened_issue2, reopened_issue1, opened_issue1])
end
end
context 'when board have a milestone' do
it 'returns opened issues without board labels and milestone applied' do
params = { board_id: board.id }
board.update_attribute(:milestone, m1)
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([opened_issue2, list1_issue2, reopened_issue1, opened_issue1])
end
end
end
context 'issues are ordered by priority' do
it 'returns opened issues when list_id is missing' do
params = { board_id: board.id }
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([opened_issue2, reopened_issue1, opened_issue1])
end
it 'returns opened issues when listing issues from Backlog' do
params = { board_id: board.id, id: backlog.id }
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([opened_issue2, reopened_issue1, opened_issue1])
end
it 'returns closed issues when listing issues from Closed' do
params = { board_id: board.id, id: closed.id }
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1])
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
params = { board_id: board.id, id: list1.id }
issues = described_class.new(group, user, params).execute
expect(issues).to match_array([list1_issue3, list1_issue1, list1_issue2])
end
end
context 'with list that does not belong to the board' do
it 'raises an error' do
list = create(:list)
service = described_class.new(group, user, board_id: board.id, id: list.id)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid list id' do
it 'raises an error' do
service = described_class.new(group, user, board_id: board.id, id: nil)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end
require 'spec_helper'
describe Boards::Issues::MoveService, services: true do
describe '#execute' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:board1) { create(:board, group: group) }
let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let(:testing) { create(:group_label, group: group, name: 'Testing') }
let!(:list1) { create(:list, board: board1, label: development, position: 0) }
let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board1) }
before do
group.add_developer(user)
end
context 'when moving an issue between lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
it 'delegates the label changes to Issues::UpdateService' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
described_class.new(group, user, params).execute(issue)
end
it 'removes the label from the list it came from and adds the label of the list it goes to' do
described_class.new(group, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, testing)
end
end
context 'when moving to closed' do
let(:board2) { create(:board, group: group) }
let(:regression) { create(:group_label, group: group, name: 'Regression') }
let!(:list3) { create(:list, board: board2, label: regression, position: 1) }
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) }
let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: closed.id } }
it 'delegates the close proceedings to Issues::CloseService' do
expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
described_class.new(group, user, params).execute(issue)
end
it 'removes all list-labels from project boards and close the issue' do
described_class.new(group, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug)
expect(issue).to be_closed
end
end
context 'when moving from closed' do
let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let(:params) { { board_id: board1.id, from_list_id: closed.id, to_list_id: list2.id } }
it 'delegates the re-open proceedings to Issues::ReopenService' do
expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
described_class.new(group, user, params).execute(issue)
end
it 'adds the label of the list it goes to and reopen the issue' do
described_class.new(group, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, testing)
expect(issue.state).to eq("opened")
end
end
context 'when moving to same list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
it 'returns false' do
expect(described_class.new(group, user, params).execute(issue)).to eq false
end
it 'keeps issues labels' do
described_class.new(group, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, development)
end
it 'sorts issues' do
[issue, issue1, issue2].each do |issue|
issue.move_to_end && issue.save!
end
params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
described_class.new(group, user, params).execute(issue)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
end
end
end
require 'spec_helper'
describe Boards::ListService do
shared_examples 'boards list service' do
let(:service) { described_class.new(parent, double) }
before do
create_list(:board, 2, parent: parent)
end
describe '#execute' do
it 'returns all issue boards when `multiple_issue_boards` is enabled' do
stub_licensed_features(multiple_issue_boards: true)
expect(service.execute.size).to eq(2)
end
it 'returns the first issue board when `multiple_issue_boards` is disabled' do
stub_licensed_features(multiple_issue_boards: false)
expect(service.execute.size).to eq(1)
end
end
end
it_behaves_like 'boards list service' do
let(:parent) { create(:project, :empty_repo) }
end
it_behaves_like 'boards list service' do
let(:parent) { create(:group) }
end
end
require 'spec_helper'
describe Boards::Lists::CreateService, services: true do
describe '#execute' do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:user) { create(:user) }
let(:label) { create(:group_label, group: group, name: 'in-progress') }
subject(:service) { described_class.new(group, user, label_id: label.id) }
before do
group.add_developer(user)
end
context 'when board lists is empty' do
it 'creates a new list at beginning of the list' do
list = service.execute(board)
expect(list.position).to eq 0
end
end
context 'when board lists has the done list' do
it 'creates a new list at beginning of the list' do
list = service.execute(board)
expect(list.position).to eq 0
end
end
context 'when board lists has labels lists' do
it 'creates a new list at end of the lists' do
create(:list, board: board, position: 0)
create(:list, board: board, position: 1)
list = service.execute(board)
expect(list.position).to eq 2
end
end
context 'when board lists has label and done lists' do
it 'creates a new list at end of the label lists' do
list1 = create(:list, board: board, position: 0)
list2 = service.execute(board)
expect(list1.reload.position).to eq 0
expect(list2.reload.position).to eq 1
end
end
context 'when provided label does not belongs to the group' do
it 'raises an error' do
label = create(:label, name: 'in-development')
service = described_class.new(group, user, label_id: label.id)
expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end
require 'spec_helper'
describe Boards::Lists::DestroyService, services: true do
describe '#execute' do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:user) { create(:user) }
context 'when list type is label' do
it 'removes list from board' do
list = create(:list, board: board)
service = described_class.new(group, user)
expect { service.execute(list) }.to change(board.lists, :count).by(-1)
end
it 'decrements position of higher lists' do
development = create(:list, board: board, position: 0)
review = create(:list, board: board, position: 1)
staging = create(:list, board: board, position: 2)
closed = board.closed_list
described_class.new(group, user).execute(development)
expect(review.reload.position).to eq 0
expect(staging.reload.position).to eq 1
expect(closed.reload.position).to be_nil
end
end
it 'does not remove list from board when list type is closed' do
list = board.closed_list
service = described_class.new(group, user)
expect { service.execute(list) }.not_to change(board.lists, :count)
end
end
end
require 'spec_helper'
describe Boards::Lists::ListService, services: true do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:label) { create(:group_label, group: group) }
let!(:list) { create(:list, board: board, label: label) }
let(:service) { described_class.new(group, double) }
describe '#execute' do
context 'when the board has a backlog list' do
let!(:backlog_list) { create(:backlog_list, board: board) }
it 'does not create a backlog list' do
expect { service.execute(board) }.not_to change(board.lists, :count)
end
it "returns board's lists" do
expect(service.execute(board)).to eq [backlog_list, list, board.closed_list]
end
end
context 'when the board does not have a backlog list' do
it 'creates a backlog list' do
expect { service.execute(board) }.to change(board.lists, :count).by(1)
end
it "returns board's lists" do
expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
end
end
end
end
require 'spec_helper'
describe Boards::Lists::MoveService, services: true do
describe '#execute' do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:user) { create(:user) }
let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) }
let!(:review) { create(:list, board: board, position: 2) }
let!(:staging) { create(:list, board: board, position: 3) }
let!(:closed) { create(:closed_list, board: board) }
context 'when list type is set to label' do
it 'keeps position of lists when new position is nil' do
service = described_class.new(group, user, position: nil)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to old position' do
service = described_class.new(group, user, position: planning.position)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is negative' do
service = described_class.new(group, user, position: -1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to number of labels lists' do
service = described_class.new(group, user, position: board.lists.label.size)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is greater than number of labels lists' do
service = described_class.new(group, user, position: board.lists.label.size + 1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'increments position of intermediate lists when new positon is equal to first position' do
service = described_class.new(group, user, position: 0)
service.execute(staging)
expect(current_list_positions).to eq [1, 2, 3, 0]
end
it 'decrements position of intermediate lists when new positon is equal to last position' do
service = described_class.new(group, user, position: board.lists.label.last.position)
service.execute(planning)
expect(current_list_positions).to eq [3, 0, 1, 2]
end
it 'decrements position of intermediate lists when new position is greater than old position' do
service = described_class.new(group, user, position: 2)
service.execute(planning)
expect(current_list_positions).to eq [2, 0, 1, 3]
end
it 'increments position of intermediate lists when new position is lower than old position' do
service = described_class.new(group, user, position: 1)
service.execute(staging)
expect(current_list_positions).to eq [0, 2, 3, 1]
end
end
it 'keeps position of lists when list type is closed' do
service = described_class.new(group, user, position: 2)
service.execute(closed)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
end
def current_list_positions
[planning, development, review, staging].map { |list| list.reload.position }
end
end
require 'spec_helper'
describe Boards::UpdateService, services: true do
describe '#execute' do
let(:group) { create(:group) }
let!(:board) { create(:board, group: group, name: 'Backend') }
it "updates board's name" do
service = described_class.new(group, double, name: 'Engineering')
service.execute(board)
expect(board).to have_attributes(name: 'Engineering')
end
it 'returns true with valid params' do
service = described_class.new(group, double, name: 'Engineering')
expect(service.execute(board)).to eq true
end
it 'returns false with invalid params' do
service = described_class.new(group, double, name: nil)
expect(service.execute(board)).to eq false
end
end
end
require 'spec_helper'
describe Boards::ListService do
let(:project) { create(:project) }
let(:service) { described_class.new(project, double) }
before do
create_list(:board, 2, project: project)
end
describe '#execute' do
it 'returns all issue boards when `multiple_issue_boards` is enabled' do
stub_licensed_features(multiple_issue_boards: true)
expect(service.execute.size).to eq(2)
end
it 'returns the first issue board when `multiple_issue_boards` is disabled' do
stub_licensed_features(multiple_issue_boards: false)
expect(service.execute.size).to eq(1)
end
end
end
......@@ -41,5 +41,23 @@ describe 'layouts/nav/sidebar/_group' do
expect(rendered).to have_text 'Contribution Analytics'
end
describe 'group issue boards link' do
it 'is not visible when there is no valid license' do
stub_licensed_features(group_issue_boards: false)
render
expect(rendered).not_to have_text 'Boards'
end
it 'is visible when there is valid license' do
stub_licensed_features(group_issue_boards: true)
render
expect(rendered).to have_text 'Boards'
end
end
end
end
FactoryGirl.define do
factory :board do
sequence(:name) { |n| "board#{n}" }
project
transient do
project nil
group nil
project_id nil
group_id nil
parent nil
end
after(:build, :stub) do |board, evaluator|
if evaluator.group
board.group = evaluator.group
elsif evaluator.group_id
board.group_id = evaluator.group_id
elsif evaluator.project
board.project = evaluator.project
elsif evaluator.project_id
board.project_id = evaluator.project_id
elsif evaluator.parent
id = evaluator.parent.id
evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
else
board.project = create(:project, :empty_repo)
end
end
after(:create) do |board|
board.lists.create(list_type: :closed)
......
......@@ -7,6 +7,7 @@ FactoryGirl.define do
group nil
project_id nil
group_id nil
parent nil
end
trait :active do
......@@ -26,6 +27,9 @@ FactoryGirl.define do
milestone.project = evaluator.project
elsif evaluator.project_id
milestone.project_id = evaluator.project_id
elsif evaluator.parent
id = evaluator.parent.id
evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
else
milestone.project = create(:project)
end
......
......@@ -8,10 +8,15 @@
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
},
"labels": {
"type": "array",
"items": {
......@@ -34,6 +39,7 @@
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"type": { "type": "string" },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
},
......
......@@ -165,6 +165,27 @@ describe('Api', () => {
done();
});
});
it('creates a new group label', (done) => {
const namespace = 'some namespace';
const labelData = { some: 'data' };
const expectedUrl = `${dummyUrlRoot}/groups/${namespace}/labels`;
const expectedData = {
label: labelData,
};
spyOn(jQuery, 'ajax').and.callFake((request) => {
expect(request.url).toEqual(expectedUrl);
expect(request.dataType).toEqual('json');
expect(request.type).toEqual('POST');
expect(request.data).toEqual(expectedData);
return sendDummyResponse();
});
Api.newLabel(namespace, null, labelData, (response) => {
expect(response).toBe(dummyResponse);
done();
});
});
});
describe('groupProjects', () => {
......
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/stores/boards_store';
import boardBlankState from '~/boards/components/board_blank_state';
......@@ -12,7 +13,7 @@ describe('Boards blank state', () => {
const Comp = Vue.extend(boardBlankState);
gl.issueBoards.BoardsStore.create();
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
if (fail) {
......
......@@ -4,6 +4,7 @@
/* global listObj */
/* global boardsMockInterceptor */
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/models/assignee';
......@@ -14,13 +15,13 @@ import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card';
import './mock_data';
describe('Issue card', () => {
describe('Board card', () => {
let vm;
beforeEach((done) => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.issueBoards.BoardsStore.detail.issue = {};
......
......@@ -3,6 +3,7 @@
/* global List */
/* global listObj */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import _ from 'underscore';
import Sortable from 'vendor/Sortable';
......@@ -24,7 +25,7 @@ describe('Board list component', () => {
document.body.appendChild(el);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
......@@ -32,6 +33,7 @@ describe('Board list component', () => {
const list = new List(listObj);
const issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
......
/* global boardsMockInterceptor */
/* global BoardService */
/* global mockBoardService */
/* global List */
/* global listObj */
......@@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => {
const BoardNewIssueComp = Vue.extend(boardNewIssue);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
......
......@@ -4,10 +4,10 @@
/* global listObj */
/* global listObjDuplicate */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import Cookies from 'js-cookie';
import '~/lib/utils/url_utility';
import '~/boards/models/issue';
import '~/boards/models/label';
......@@ -20,7 +20,7 @@ import './mock_data';
describe('Store', () => {
beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => {
......@@ -78,7 +78,7 @@ describe('Store', () => {
it('persists new list', (done) => {
gl.issueBoards.BoardsStore.new({
title: 'Test',
type: 'label',
list_type: 'label',
label: {
id: 1,
title: 'Testing',
......@@ -210,6 +210,7 @@ describe('Store', () => {
it('moves issue in list', (done) => {
const issue = new ListIssue({
title: 'Testing',
id: 2,
iid: 2,
confidential: false,
labels: [],
......
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/services/board_service';
import '~/boards/components/board';
import '~/boards/models/list';
import '../mock_data';
describe('Board component', () => {
let vm;
......@@ -13,8 +15,12 @@ describe('Board component', () => {
el = document.createElement('div');
document.body.appendChild(el);
// eslint-disable-next-line no-undef
gl.boardService = new BoardService('/', '/', 1);
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
vm = new gl.issueBoards.Board({
propsData: {
......
......@@ -37,6 +37,7 @@ describe('Issue card component', () => {
list = listObj;
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [list.label],
......@@ -51,6 +52,7 @@ describe('Issue card component', () => {
issue,
issueLinkBase: '/test',
rootPath: '/',
groupId: null,
};
},
components: {
......@@ -60,6 +62,7 @@ describe('Issue card component', () => {
<issue-card
:issue="issue"
:list="list"
:group-id="groupId"
:issue-link-base="issueLinkBase"
:root-path="rootPath"></issue-card>
`,
......@@ -238,65 +241,107 @@ describe('Issue card component', () => {
});
describe('labels', () => {
describe('exists', () => {
beforeEach((done) => {
component.issue.addLabel(label1);
beforeEach((done) => {
component.issue.addLabel(label1);
Vue.nextTick(() => done());
});
Vue.nextTick(() => done());
});
it('renders list label', () => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
it('renders list label', () => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.title);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.title);
});
expect(
nodes.includes(label1.description),
).toBe(true);
});
expect(
nodes.includes(label1.description),
).toBe(true);
});
it('sets label description as title', () => {
expect(
component.$el.querySelector('.label').getAttribute('title'),
).toContain(label1.description);
});
it('sets label description as title', () => {
expect(
component.$el.querySelector('.label').getAttribute('title'),
).toContain(label1.description);
it('sets background color of button', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.style.backgroundColor);
});
it('sets background color of button', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.style.backgroundColor);
});
expect(
nodes.includes(label1.color),
).toBe(true);
});
expect(
nodes.includes(label1.color),
).toBe(true);
});
it('does not render label if label does not have an ID', (done) => {
component.issue.addLabel(new ListLabel({
title: 'closed',
}));
it('does not render label if label does not have an ID', (done) => {
component.issue.addLabel(new ListLabel({
title: 'closed',
}));
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('Closed');
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
done();
})
.catch(done.fail);
});
done();
})
.catch(done.fail);
});
it('shows group labels on group boards', (done) => {
component.issue.addLabel(new ListLabel({
id: _.random(10000),
title: 'Group label',
type: 'GroupLabel',
}));
component.groupId = 1;
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(3);
expect(
component.$el.textContent,
).toContain('Group label');
done();
})
.catch(done.fail);
});
it('does not show project labels on group boards', (done) => {
component.issue.addLabel(new ListLabel({
id: 123,
title: 'Project label',
type: 'ProjectLabel',
}));
component.groupId = 1;
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('Project label');
done();
})
.catch(done.fail);
});
});
});
/* eslint-disable comma-dangle */
/* global BoardService */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import '~/lib/utils/url_utility';
......@@ -16,11 +17,12 @@ describe('Issue model', () => {
let issue;
beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [{
......
/* eslint-disable comma-dangle */
/* global boardsMockInterceptor */
/* global BoardService */
/* global mockBoardService */
/* global List */
/* global ListIssue */
/* global listObj */
......@@ -22,7 +23,9 @@ describe('List model', () => {
beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService({
bulkUpdatePath: '/test/issue-boards/board/1/lists',
});
gl.issueBoards.BoardsStore.create();
list = new List(listObj);
......@@ -92,6 +95,7 @@ describe('List model', () => {
const listDup = new List(listObjDuplicate);
const issue = new ListIssue({
title: 'Testing',
id: _.random(10000),
iid: _.random(10000),
confidential: false,
labels: [list.label, listDup.label],
......@@ -118,7 +122,8 @@ describe('List model', () => {
for (let i = 0; i < 30; i += 1) {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000) + i,
id: _.random(10000) + i,
iid: _.random(1000) + i,
confidential: false,
labels: [list.label],
assignees: [],
......@@ -137,7 +142,7 @@ describe('List model', () => {
it('does not increase page number if issue count is less than the page size', () => {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
......@@ -156,7 +161,7 @@ describe('List model', () => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
json() {
return {
iid: 42,
id: 42,
};
},
}));
......@@ -165,14 +170,14 @@ describe('List model', () => {
it('adds new issue to top of list', (done) => {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
}));
const dummyIssue = new ListIssue({
title: 'new issue',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
......
/* global boardsMockInterceptor */
/* global boardObj */
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import milestoneSelect from '~/boards/components/milestone_select';
......@@ -16,7 +17,7 @@ describe('Milestone select component', () => {
const MilestoneComp = Vue.extend(milestoneSelect);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => {
......
/* global BoardService */
/* eslint-disable comma-dangle, no-unused-vars, quote-props */
const boardObj = {
id: 1,
......@@ -33,15 +34,15 @@ const listObjDuplicate = {
const BoardsMockData = {
'GET': {
'/test/issue-boards/board/1/lists{/id}/issues': {
'/test/boards/1{/id}/issues': {
issues: [{
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
}],
size: 1
},
'/test/issue-boards/milestones.json': [{
id: 1,
......@@ -49,7 +50,7 @@ const BoardsMockData = {
}],
},
'POST': {
'/test/issue-boards/board/1/lists{/id}': listObj
'/test/boards/1{/id}': listObj
},
'PUT': {
'/test/issue-boards/board/1/lists{/id}': {}
......@@ -67,8 +68,23 @@ const boardsMockInterceptor = (request, next) => {
}));
};
const mockBoardService = (opts = {}) => {
const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/board';
const listsEndpoint = opts.listsEndpoint || '/test/boards/1';
const bulkUpdatePath = opts.bulkUpdatePath || '';
const boardId = opts.boardId || '1';
return new BoardService({
boardsEndpoint,
listsEndpoint,
bulkUpdatePath,
boardId,
});
};
window.boardObj = boardObj;
window.listObj = listObj;
window.listObjDuplicate = listObjDuplicate;
window.BoardsMockData = BoardsMockData;
window.boardsMockInterceptor = boardsMockInterceptor;
window.mockBoardService = mockBoardService;
......@@ -18,6 +18,7 @@ describe('Modal store', () => {
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
......@@ -25,6 +26,7 @@ describe('Modal store', () => {
});
issue2 = new ListIssue({
title: 'Testing',
id: 2,
iid: 2,
confidential: false,
labels: [],
......
......@@ -10,6 +10,7 @@ describe('Dropdown User', () => {
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
dropdownUser = new gl.DropdownUser({
......@@ -38,6 +39,7 @@ describe('Dropdown User', () => {
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
});
it('should return endpoint', () => {
......
......@@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do
let(:label) { create(:label, project: project, name: 'in-progress') }
let!(:list) { create(:list, board: board, label: label, position: 0) }
subject(:service) { described_class.new(project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
before do
project.team << [user, :developer]
......
......@@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do
issue.move_to_end && issue.save!
end
params.merge!(move_after_iid: issue1.iid, move_before_iid: issue2.iid)
params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
described_class.new(project, user, params).execute(issue)
......
......@@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do
issue.save
end
opts[:move_between_iids] = [issue1.iid, issue2.iid]
opts[:move_between_ids] = [issue1.id, issue2.id]
update_issue(opts)
......
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