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 = { ...@@ -6,7 +6,8 @@ const Api = {
namespacesPath: '/api/:version/namespaces.json', namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json', groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json?simple=true', 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', licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key', gitignorePath: '/api/:version/templates/gitignores/:key',
gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key', gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
...@@ -74,9 +75,16 @@ const Api = { ...@@ -74,9 +75,16 @@ const Api = {
}, },
newLabel(namespacePath, projectPath, data, callback) { newLabel(namespacePath, projectPath, data, callback) {
const url = Api.buildUrl(Api.labelsPath) let url;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath) .replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath); .replace(':project_path', projectPath);
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
return $.ajax({ return $.ajax({
url, url,
type: 'POST', type: 'POST',
......
...@@ -11,6 +11,7 @@ import './models/issue'; ...@@ -11,6 +11,7 @@ import './models/issue';
import './models/label'; import './models/label';
import './models/list'; import './models/list';
import './models/milestone'; import './models/milestone';
import './models/project';
import './models/assignee'; import './models/assignee';
import './stores/boards_store'; import './stores/boards_store';
import './stores/modal_store'; import './stores/modal_store';
...@@ -58,7 +59,8 @@ $(() => { ...@@ -58,7 +59,8 @@ $(() => {
data: { data: {
state: Store.state, state: Store.state,
loading: true, loading: true,
endpoint: $boardApp.dataset.endpoint, boardsEndpoint: $boardApp.dataset.boardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId, boardId: $boardApp.dataset.boardId,
disabled: $boardApp.dataset.disabled === 'true', disabled: $boardApp.dataset.disabled === 'true',
issueLinkBase: $boardApp.dataset.issueLinkBase, issueLinkBase: $boardApp.dataset.issueLinkBase,
...@@ -84,8 +86,13 @@ $(() => { ...@@ -84,8 +86,13 @@ $(() => {
Store.updateFiltersUrl(true); Store.updateFiltersUrl(true);
} }
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); gl.boardService = new BoardService({
Store.rootPath = this.endpoint; 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 = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]);
this.filterManager.setup(); this.filterManager.setup();
...@@ -149,7 +156,7 @@ $(() => { ...@@ -149,7 +156,7 @@ $(() => {
focusModeAvailable: gl.utils.convertPermissionToBoolean( focusModeAvailable: gl.utils.convertPermissionToBoolean(
$boardApp.dataset.focusModeAvailable, $boardApp.dataset.focusModeAvailable,
), ),
canAdminList: gl.utils.convertPermissionToBoolean( canAdminList: this.$options.el && gl.utils.convertPermissionToBoolean(
this.$options.el.dataset.canAdminList, this.$options.el.dataset.canAdminList,
), ),
}; };
...@@ -161,6 +168,9 @@ $(() => { ...@@ -161,6 +168,9 @@ $(() => {
}, },
computed: { computed: {
disabled() { disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length; return !this.store.lists.filter(list => !list.preset).length;
}, },
tooltipTitle() { tooltipTitle() {
...@@ -188,14 +198,6 @@ $(() => { ...@@ -188,14 +198,6 @@ $(() => {
this.toggleModal(true); this.toggleModal(true);
} }
}, },
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
}, },
mounted() { mounted() {
this.updateTooltip(); this.updateTooltip();
...@@ -214,6 +216,30 @@ $(() => { ...@@ -214,6 +216,30 @@ $(() => {
@click="openModal"> @click="openModal">
Add issues Add issues
</button> </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 <a
href="#" href="#"
class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn" class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
......
...@@ -16,6 +16,7 @@ export default { ...@@ -16,6 +16,7 @@ export default {
:list="list" :list="list"
:issue="issue" :issue="issue"
:issue-link-base="issueLinkBase" :issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath" :root-path="rootPath"
:update-filters="true" /> :update-filters="true" />
</li> </li>
...@@ -30,6 +31,7 @@ export default { ...@@ -30,6 +31,7 @@ export default {
disabled: Boolean, disabled: Boolean,
index: Number, index: Number,
rootPath: String, rootPath: String,
groupId: Number,
}, },
data() { data() {
return { return {
......
...@@ -9,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore; ...@@ -9,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardList', name: 'BoardList',
props: { props: {
groupId: {
type: Number,
required: false,
default: 0,
},
disabled: { disabled: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -77,7 +82,7 @@ export default { ...@@ -77,7 +82,7 @@ export default {
this.showIssueForm = !this.showIssueForm; this.showIssueForm = !this.showIssueForm;
}, },
onScroll() { onScroll() {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { if (!this.list.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
this.loadNextPage(); this.loadNextPage();
} }
}, },
...@@ -160,17 +165,19 @@ export default { ...@@ -160,17 +165,19 @@ export default {
template: ` template: `
<div class="board-list-component"> <div class="board-list-component">
<div <div
key="loading"
class="board-list-loading text-center" class="board-list-loading text-center"
aria-label="Loading issues" aria-label="Loading issues"
v-if="loading"> v-if="loading">
<loading-icon /> <loading-icon />
</div> </div>
<transition name="slide-down">
<board-new-issue <board-new-issue
key="newIssue"
:group-id="groupId"
:list="list" :list="list"
v-if="list.type !== 'closed' && showIssueForm"/> v-if="list.type !== 'closed' && showIssueForm"/>
</transition>
<ul <ul
key="list"
class="board-list" class="board-list"
v-show="!loading" v-show="!loading"
ref="list" ref="list"
...@@ -183,6 +190,7 @@ export default { ...@@ -183,6 +190,7 @@ export default {
:list="list" :list="list"
:issue="issue" :issue="issue"
:issue-link-base="issueLinkBase" :issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath" :root-path="rootPath"
:disabled="disabled" :disabled="disabled"
:key="issue.id" /> :key="issue.id" />
......
/* global ListIssue */ /* global ListIssue */
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
props: { props: {
list: Object, groupId: {
type: Number,
required: false,
default: 0,
},
list: {
type: Object,
required: true,
},
}, },
data() { data() {
return { return {
title: '', title: '',
error: false, error: false,
selectedProject: {},
}; };
}, },
components: {
'project-select': ProjectSelect,
},
computed: {
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
},
methods: { methods: {
submit(e) { submit(e) {
e.preventDefault(); e.preventDefault();
...@@ -27,6 +48,7 @@ export default { ...@@ -27,6 +48,7 @@ export default {
labels, labels,
subscribed: true, subscribed: true,
assignees: [], assignees: [],
project_id: this.selectedProject.id,
}); });
if (Store.state.currentBoard) { if (Store.state.currentBoard) {
...@@ -59,12 +81,17 @@ export default { ...@@ -59,12 +81,17 @@ export default {
this.title = ''; this.title = '';
eventHub.$emit(`hide-issue-form-${this.list.id}`); eventHub.$emit(`hide-issue-form-${this.list.id}`);
}, },
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
}, },
mounted() { mounted() {
this.$refs.input.focus(); this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
}, },
template: ` template: `
<div class="card board-new-issue-form"> <div class="board-new-issue-form">
<div class="card">
<form @submit="submit($event)"> <form @submit="submit($event)">
<div class="flash-container" <div class="flash-container"
v-if="error"> v-if="error">
...@@ -82,10 +109,14 @@ export default { ...@@ -82,10 +109,14 @@ export default {
ref="input" ref="input"
autocomplete="off" autocomplete="off"
:id="list.id + '-title'" /> :id="list.id + '-title'" />
<project-select
v-if="groupId"
:groupId="groupId"
/>
<div class="clearfix prepend-top-10"> <div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left" <button class="btn btn-success pull-left"
type="submit" type="submit"
:disabled="title === ''" :disabled="disabled"
ref="submit-button"> ref="submit-button">
Submit issue Submit issue
</button> </button>
...@@ -97,5 +128,6 @@ export default { ...@@ -97,5 +128,6 @@ export default {
</div> </div>
</form> </form>
</div> </div>
</div>
`, `,
}; };
...@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false, required: false,
default: false, default: false,
}, },
groupId: {
type: Number,
required: false,
},
}, },
data() { data() {
return { return {
...@@ -64,10 +68,19 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -64,10 +68,19 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit; return this.issue.assignees.length > this.numberOverLimit;
}, },
cardUrl() { 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() { issueId() {
return `#${this.issue.id}`; if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
}, },
showLabelFooter() { showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined; return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
...@@ -98,6 +111,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -98,6 +111,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
}, },
showLabel(label) { showLabel(label) {
if (!label.id) return false; if (!label.id) return false;
if (this.groupId && label.type === 'ProjectLabel') return false;
return true; return true;
}, },
filterByLabel(label, e) { filterByLabel(label, e) {
...@@ -143,7 +157,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -143,7 +157,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
:title="issue.title">{{ issue.title }}</a> :title="issue.title">{{ issue.title }}</a>
<span <span
class="card-number" class="card-number"
v-if="issue.id" v-if="issueId"
> >
{{ issueId }} {{ issueId }}
</span> </span>
......
...@@ -29,7 +29,7 @@ gl.issueBoards.ModalFooter = Vue.extend({ ...@@ -29,7 +29,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
const firstListIndex = 1; const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex]; const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues(); const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId); const issueIds = selectedIssues.map(issue => issue.id);
// Post the data to the backend // Post the data to the backend
gl.boardService.bulkUpdate(issueIds, { gl.boardService.bulkUpdate(issueIds, {
......
...@@ -27,7 +27,7 @@ gl.issueBoards.newListDropdownInit = () => { ...@@ -27,7 +27,7 @@ gl.issueBoards.newListDropdownInit = () => {
$this.glDropdown({ $this.glDropdown({
data(term, callback) { data(term, callback) {
$.get($this.attr('data-labels')) $.get($this.attr('data-list-labels-path'))
.then((resp) => { .then((resp) => {
callback(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({ ...@@ -18,22 +18,38 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object, type: Object,
required: true, required: true,
}, },
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate.replace(':project_path', this.issue.project.path);
},
}, },
methods: { methods: {
removeIssue() { removeIssue() {
const issue = this.issue; const issue = this.issue;
const lists = issue.getLists(); 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 = { const data = {
remove_label_ids: labelIds, issue: {
label_ids: labelIds,
},
}; };
if (Store.state.currentBoard.milestone_id) { if (Store.state.currentBoard.milestone_id) {
data.milestone_id = -1; data.issue.milestone_id = -1;
} }
// Post the remove data // 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'); new Flash('Failed to remove issue from board, please try again.', 'alert');
lists.forEach((list) => { lists.forEach((list) => {
......
...@@ -4,11 +4,12 @@ ...@@ -4,11 +4,12 @@
/* global ListAssignee */ /* global ListAssignee */
import Vue from 'vue'; import Vue from 'vue';
import IssueProject from './project';
class ListIssue { class ListIssue {
constructor (obj, defaultAvatar) { constructor (obj, defaultAvatar) {
this.globalId = obj.id; this.id = obj.id;
this.id = obj.iid; this.iid = obj.iid;
this.title = obj.title; this.title = obj.title;
this.confidential = obj.confidential; this.confidential = obj.confidential;
this.dueDate = obj.due_date; this.dueDate = obj.due_date;
...@@ -18,6 +19,11 @@ class ListIssue { ...@@ -18,6 +19,11 @@ class ListIssue {
this.selected = false; this.selected = false;
this.position = obj.relative_position || Infinity; this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id; this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
if (obj.project) {
this.project = new IssueProject(obj.project);
}
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
...@@ -88,7 +94,8 @@ class ListIssue { ...@@ -88,7 +94,8 @@ class ListIssue {
data.issue.label_ids = ['']; 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 { ...@@ -4,6 +4,7 @@ class ListLabel {
constructor (obj) { constructor (obj) {
this.id = obj.id; this.id = obj.id;
this.title = obj.title; this.title = obj.title;
this.type = obj.type;
this.color = obj.color; this.color = obj.color;
this.textColor = obj.text_color; this.textColor = obj.text_color;
this.description = obj.description; this.description = obj.description;
......
...@@ -110,12 +110,14 @@ class List { ...@@ -110,12 +110,14 @@ class List {
return gl.boardService.newIssue(this.id, issue) return gl.boardService.newIssue(this.id, issue)
.then(resp => resp.json()) .then(resp => resp.json())
.then((data) => { .then((data) => {
issue.id = data.iid; issue.id = data.id;
issue.iid = data.iid;
issue.milestone = data.milestone; issue.milestone = data.milestone;
issue.project = data.project;
if (this.issuesSize > 1) { if (this.issuesSize > 1) {
const moveBeforeIid = this.issues[1].id; const moveBeforeId = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid); gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
} }
}); });
} }
...@@ -127,19 +129,19 @@ class List { ...@@ -127,19 +129,19 @@ class List {
} }
addIssue (issue, listFrom, newIndex) { addIssue (issue, listFrom, newIndex) {
let moveBeforeIid = null; let moveBeforeId = null;
let moveAfterIid = null; let moveAfterId = null;
if (!this.findIssue(issue.id)) { if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) { if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue); this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) { if (this.issues[newIndex - 1]) {
moveBeforeIid = this.issues[newIndex - 1].id; moveBeforeId = this.issues[newIndex - 1].id;
} }
if (this.issues[newIndex + 1]) { if (this.issues[newIndex + 1]) {
moveAfterIid = this.issues[newIndex + 1].id; moveAfterId = this.issues[newIndex + 1].id;
} }
} else { } else {
this.issues.push(issue); this.issues.push(issue);
...@@ -152,30 +154,30 @@ class List { ...@@ -152,30 +154,30 @@ class List {
if (listFrom) { if (listFrom) {
this.issuesSize += 1; 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(oldIndex, 1);
this.issues.splice(newIndex, 0, issue); 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(() => { .catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) { updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid) gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => { .catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
findIssue (id) { findIssue (id) {
return this.issues.filter(issue => issue.id === id)[0]; return this.issues.find(issue => issue.id === id);
} }
removeIssue (removeIssue) { removeIssue (removeIssue) {
......
export default class IssueProject {
constructor(obj) {
this.id = obj.id;
this.path = obj.path;
}
}
...@@ -3,21 +3,21 @@ ...@@ -3,21 +3,21 @@
import Vue from 'vue'; import Vue from 'vue';
class BoardService { class BoardService {
constructor (root, bulkUpdatePath, boardId) { constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
this.boards = Vue.resource(`${root}{/id}.json`, {}, { this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: { issues: {
method: 'GET', 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: { generate: {
method: 'POST', method: 'POST',
url: `${root}/${boardId}/lists/generate.json` url: `${listsEndpoint}/generate.json`
} }
}); });
this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {}); this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, { this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: { bulkUpdate: {
method: 'POST', method: 'POST',
url: bulkUpdatePath, url: bulkUpdatePath,
...@@ -71,12 +71,12 @@ class BoardService { ...@@ -71,12 +71,12 @@ class BoardService {
return this.issues.get(data); 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 }, { return this.issue.update({ id }, {
from_list_id, from_list_id,
to_list_id, to_list_id,
move_before_iid, move_before_id,
move_after_iid, move_after_id,
}); });
} }
......
...@@ -15,8 +15,8 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -15,8 +15,8 @@ class DropdownUser extends gl.FilteredSearchDropdown {
params: { params: {
per_page: 20, per_page: 20,
active: true, active: true,
project_id: this.getProjectId(),
current_user: true, current_user: true,
...this.projectOrGroupId(),
}, },
searchValueFunction: this.getSearchInput.bind(this), searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate, loadingTemplate: this.loadingTemplate,
...@@ -47,10 +47,27 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -47,10 +47,27 @@ class DropdownUser extends gl.FilteredSearchDropdown {
super.renderContent(forceShowList); super.renderContent(forceShowList);
} }
getGroupId() {
return this.input.getAttribute('data-group-id');
}
getProjectId() { getProjectId() {
return this.input.getAttribute('data-project-id'); 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() { getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input); const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
......
...@@ -2,13 +2,14 @@ import DropLab from '~/droplab/drop_lab'; ...@@ -2,13 +2,14 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager { class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', tokenizer, page) { constructor(baseEndpoint = '', tokenizer, page, isGroup) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer; this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page; this.page = page;
this.groupsOnly = page === 'boards' && isGroup;
if (this.page === 'issues' || this.page === 'boards') { if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE;
...@@ -47,7 +48,7 @@ class FilteredSearchDropdownManager { ...@@ -47,7 +48,7 @@ class FilteredSearchDropdownManager {
reference: null, reference: null,
gl: 'DropdownNonUser', gl: 'DropdownNonUser',
extraArguments: { extraArguments: {
endpoint: `${this.baseEndpoint}/milestones.json`, endpoint: `${this.baseEndpoint}/milestones.json${this.groupsOnly ? '?only_group_milestones=true' : ''}`,
symbol: '%', symbol: '%',
}, },
element: this.container.querySelector('#js-dropdown-milestone'), element: this.container.querySelector('#js-dropdown-milestone'),
...@@ -56,7 +57,7 @@ class FilteredSearchDropdownManager { ...@@ -56,7 +57,7 @@ class FilteredSearchDropdownManager {
reference: null, reference: null,
gl: 'DropdownNonUser', gl: 'DropdownNonUser',
extraArguments: { extraArguments: {
endpoint: `${this.baseEndpoint}/labels.json`, endpoint: `${this.baseEndpoint}/labels.json${this.groupsOnly ? '?only_group_labels=true' : ''}`,
symbol: '~', symbol: '~',
preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing, preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
}, },
......
...@@ -63,7 +63,12 @@ class FilteredSearchManager { ...@@ -63,7 +63,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; 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.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore, this.recentSearchesStore,
......
...@@ -18,14 +18,20 @@ class Subscription { ...@@ -18,14 +18,20 @@ class Subscription {
} }
button.classList.add('disabled'); 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 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, () => { $.post(toggleActionUrl, () => {
button.classList.remove('disabled'); button.classList.remove('disabled');
// hack to allow this to work with the issue boards Vue object if (isBoardsPage) {
if (document.querySelector('html').classList.contains('issue-boards-page')) {
gl.issueBoards.boardStoreIssueSet( gl.issueBoards.boardStoreIssueSet(
'subscribed', 'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed, !gl.issueBoards.BoardsStore.detail.issue.subscribed,
......
...@@ -183,7 +183,7 @@ ...@@ -183,7 +183,7 @@
width: auto; width: auto;
top: 100%; top: 100%;
left: 0; left: 0;
z-index: 200; z-index: 300;
min-width: 240px; min-width: 240px;
max-width: 500px; max-width: 500px;
margin-top: 2px; margin-top: 2px;
......
...@@ -147,13 +147,12 @@ ...@@ -147,13 +147,12 @@
} }
.board-title { .board-title {
position: initial;
padding: 0; padding: 0;
border-bottom: 0; border-bottom: 0;
> span { > span {
display: block; display: block;
transform: rotate(90deg) translate(25px, 0); transform: rotate(90deg) translate(35px, 10px);
} }
} }
...@@ -181,11 +180,18 @@ ...@@ -181,11 +180,18 @@
} }
.board-header { .board-header {
border-top-left-radius: $border-radius-default; position: relative;
border-top-right-radius: $border-radius-default;
&.has-border { &.has-border::before {
border-top: 3px solid; 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-top: -1px;
margin-right: -1px; margin-right: -1px;
margin-left: -1px; margin-left: -1px;
...@@ -206,12 +212,16 @@ ...@@ -206,12 +212,16 @@
} }
.board-title { .board-title {
position: relative;
margin: 0; margin: 0;
padding: $gl-padding; padding: 12px $gl-padding;
padding-bottom: ($gl-padding + 3px);
font-size: 1em; font-size: 1em;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
display: flex;
align-items: center;
}
.board-title-text {
margin-right: auto;
} }
.board-delete { .board-delete {
...@@ -259,43 +269,10 @@ ...@@ -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 { .board-list-component {
height: calc(100% - 49px); height: calc(100% - 49px);
overflow: hidden; overflow: hidden;
position: relative;
} }
.board-list { .board-list {
...@@ -466,7 +443,7 @@ ...@@ -466,7 +443,7 @@
} }
.board-new-issue-form { .board-new-issue-form {
z-index: 1; z-index: 4;
margin: 5px; 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 ...@@ -14,7 +14,13 @@ class Groups::LabelsController < Groups::ApplicationController
end end
format.json do 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) render json: LabelSerializer.new.represent_appearance(available_labels)
end end
end end
...@@ -28,6 +34,8 @@ class Groups::LabelsController < Groups::ApplicationController ...@@ -28,6 +34,8 @@ class Groups::LabelsController < Groups::ApplicationController
def create def create
@label = Labels::CreateService.new(label_params).execute(group: group) @label = Labels::CreateService.new(label_params).execute(group: group)
respond_to do |format|
format.html do
if @label.valid? if @label.valid?
redirect_to group_labels_path(@group) redirect_to group_labels_path(@group)
else else
...@@ -35,6 +43,12 @@ class Groups::LabelsController < Groups::ApplicationController ...@@ -35,6 +43,12 @@ class Groups::LabelsController < Groups::ApplicationController
end end
end end
format.json do
render json: LabelSerializer.new.represent_appearance(@label)
end
end
end
def edit def edit
@previous_labels_path = previous_labels_path @previous_labels_path = previous_labels_path
end end
......
...@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page]) @milestones = Kaminari.paginate_array(milestones).page(params[:page])
end end
format.json do 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 end
end end
...@@ -78,7 +78,13 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -78,7 +78,13 @@ class Groups::MilestonesController < Groups::ApplicationController
search_params = params.merge(group_ids: group.id) search_params = params.merge(group_ids: group.id)
milestones = MilestonesFinder.new(search_params).execute 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 milestones + legacy_milestones
end 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 class Projects::BoardsController < Projects::ApplicationController
prepend EE::Projects::BoardsController prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses
include IssuableCollections include IssuableCollections
before_action :authorize_read_board!, only: [:index, :show] before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
def index def index
@boards = ::Boards::ListService.new(project, current_user).execute @boards = Boards::ListService.new(project, current_user).execute
respond_to do |format| respond_with_boards
format.html
format.json do
render json: serialize_as_json(@boards)
end
end
end end
def show def show
@board = project.boards.find(params[:id]) @board = project.boards.find(params[:id])
respond_to do |format| respond_with_board
format.html
format.json do
render json: serialize_as_json(@board)
end
end
end end
private 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! def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project) return access_denied! unless can?(current_user, :read_board, project)
end end
......
module BoardsHelper module BoardsHelper
prepend EE::BoardsHelper prepend EE::BoardsHelper
def board_data def board
board = @board || @boards.first @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_id: board.id,
board_milestone_title: board&.milestone&.title, disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
disabled: "#{!can?(current_user, :admin_list, @project)}", issue_link_base: build_issue_link_base,
issue_link_base: project_issues_path(@project),
root_path: root_path, 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) default_avatar: image_path(default_avatar)
} }
end end
def build_issue_link_base
project_issues_path(@project)
end
def current_board_json def current_board_json
board = @board || @boards.first board = @board || @boards.first
...@@ -26,4 +32,51 @@ module BoardsHelper ...@@ -26,4 +32,51 @@ module BoardsHelper
} }
) )
end 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 end
...@@ -29,7 +29,7 @@ module FormHelper ...@@ -29,7 +29,7 @@ module FormHelper
first_user: current_user&.username, first_user: current_user&.username,
null_user: true, null_user: true,
current_user: true, current_user: true,
project_id: @project.id, project_id: @project&.id,
field_name: "issue[assignee_ids][]", field_name: "issue[assignee_ids][]",
default_label: 'Unassigned', default_label: 'Unassigned',
'max-select': 1, 'max-select': 1,
......
...@@ -369,6 +369,14 @@ module IssuablesHelper ...@@ -369,6 +369,14 @@ module IssuablesHelper
end end
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) def issuable_sidebar_options(issuable, can_edit_issuable)
{ {
endpoint: "#{issuable_json_path(issuable)}?basic=true", endpoint: "#{issuable_json_path(issuable)}?basic=true",
......
...@@ -121,13 +121,14 @@ module LabelsHelper ...@@ -121,13 +121,14 @@ module LabelsHelper
end end
end end
def labels_filter_path def labels_filter_path(only_group_labels = false)
return group_labels_path(@group, :json) if @group
project = @target_project || @project project = @target_project || @project
if project if project
project_labels_path(project, :json) 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 else
dashboard_labels_path(:json) dashboard_labels_path(:json)
end end
......
...@@ -137,7 +137,8 @@ module SearchHelper ...@@ -137,7 +137,8 @@ module SearchHelper
end end
def search_filter_input_options(type) def search_filter_input_options(type)
opts = { opts =
{
id: "filtered-search-#{type}", id: "filtered-search-#{type}",
placeholder: 'Search or filter results...', placeholder: 'Search or filter results...',
data: { data: {
...@@ -150,6 +151,7 @@ module SearchHelper ...@@ -150,6 +151,7 @@ module SearchHelper
opts[:data]['base-endpoint'] = project_path(@project) opts[:data]['base-endpoint'] = project_path(@project)
else else
# Group context # Group context
opts[:data]['group-id'] = @group.id
opts[:data]['base-endpoint'] = group_canonical_path(@group) opts[:data]['base-endpoint'] = group_canonical_path(@group)
end end
......
...@@ -5,7 +5,13 @@ class Board < ActiveRecord::Base ...@@ -5,7 +5,13 @@ class Board < ActiveRecord::Base
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent 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 def backlog_list
lists.merge(List.backlog).take lists.merge(List.backlog).take
......
...@@ -10,8 +10,12 @@ module RelativePositioning ...@@ -10,8 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours after_save :save_positionable_neighbours
end end
def project_ids
[project.id]
end
def max_relative_position def max_relative_position
self.class.in_projects(project.id).maximum(:relative_position) self.class.in_projects(project_ids).maximum(:relative_position)
end end
def prev_relative_position def prev_relative_position
...@@ -19,7 +23,7 @@ module RelativePositioning ...@@ -19,7 +23,7 @@ module RelativePositioning
if self.relative_position if self.relative_position
prev_pos = self.class prev_pos = self.class
.in_projects(project.id) .in_projects(project_ids)
.where('relative_position < ?', self.relative_position) .where('relative_position < ?', self.relative_position)
.maximum(:relative_position) .maximum(:relative_position)
end end
...@@ -32,7 +36,7 @@ module RelativePositioning ...@@ -32,7 +36,7 @@ module RelativePositioning
if self.relative_position if self.relative_position
next_pos = self.class next_pos = self.class
.in_projects(project.id) .in_projects(project_ids)
.where('relative_position > ?', self.relative_position) .where('relative_position > ?', self.relative_position)
.minimum(:relative_position) .minimum(:relative_position)
end end
...@@ -59,7 +63,7 @@ module RelativePositioning ...@@ -59,7 +63,7 @@ module RelativePositioning
pos_after = before.next_relative_position pos_after = before.next_relative_position
if before.shift_after? 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 issue_to_move.move_after
@positionable_neighbours = [issue_to_move] @positionable_neighbours = [issue_to_move]
...@@ -74,7 +78,7 @@ module RelativePositioning ...@@ -74,7 +78,7 @@ module RelativePositioning
pos_before = after.prev_relative_position pos_before = after.prev_relative_position
if after.shift_before? 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 issue_to_move.move_before
@positionable_neighbours = [issue_to_move] @positionable_neighbours = [issue_to_move]
......
...@@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord' ...@@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base class Issue < ActiveRecord::Base
prepend EE::Issue prepend EE::Issue
prepend EE::RelativePositioning
include InternalId include InternalId
include Issuable include Issuable
......
class Label < ActiveRecord::Base class Label < ActiveRecord::Base
# EE specific
prepend EE::Label
include CacheMarkdownField include CacheMarkdownField
include Referable include Referable
include Subscribable include Subscribable
...@@ -34,7 +37,8 @@ class Label < ActiveRecord::Base ...@@ -34,7 +37,8 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) } scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) } 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) def self.prioritized(project)
joins(:priorities) joins(:priorities)
...@@ -172,6 +176,7 @@ class Label < ActiveRecord::Base ...@@ -172,6 +176,7 @@ class Label < ActiveRecord::Base
def as_json(options = {}) def as_json(options = {})
super(options).tap do |json| super(options).tap do |json|
json[:type] = self.try(:type)
json[:priority] = priority(options[:project]) if options.key?(:project) json[:priority] = priority(options[:project]) if options.key?(:project)
end end
end end
......
...@@ -18,6 +18,7 @@ class License < ActiveRecord::Base ...@@ -18,6 +18,7 @@ class License < ActiveRecord::Base
ISSUABLE_DEFAULT_TEMPLATES_FEATURE = 'GitLab_IssuableDefaultTemplates'.freeze ISSUABLE_DEFAULT_TEMPLATES_FEATURE = 'GitLab_IssuableDefaultTemplates'.freeze
ISSUE_BOARD_FOCUS_MODE_FEATURE = 'GitLab_IssueBoardFocusMode'.freeze ISSUE_BOARD_FOCUS_MODE_FEATURE = 'GitLab_IssueBoardFocusMode'.freeze
ISSUE_BOARD_MILESTONE_FEATURE = 'GitLab_IssueBoardMilestone'.freeze ISSUE_BOARD_MILESTONE_FEATURE = 'GitLab_IssueBoardMilestone'.freeze
GROUP_ISSUE_BOARDS_FEATURE = 'GitLab_GroupIssueBoards'.freeze
ISSUE_WEIGHTS_FEATURE = 'GitLab_IssueWeights'.freeze ISSUE_WEIGHTS_FEATURE = 'GitLab_IssueWeights'.freeze
JENKINS_INTEGRATION_FEATURE = 'GitLab_JenkinsIntegration'.freeze JENKINS_INTEGRATION_FEATURE = 'GitLab_JenkinsIntegration'.freeze
JIRA_DEV_PANEL_INTEGRATION_FEATURE = 'GitLab_JiraDevelopmentPanelIntegration'.freeze JIRA_DEV_PANEL_INTEGRATION_FEATURE = 'GitLab_JiraDevelopmentPanelIntegration'.freeze
...@@ -62,6 +63,7 @@ class License < ActiveRecord::Base ...@@ -62,6 +63,7 @@ class License < ActiveRecord::Base
issuable_default_templates: ISSUABLE_DEFAULT_TEMPLATES_FEATURE, issuable_default_templates: ISSUABLE_DEFAULT_TEMPLATES_FEATURE,
issue_board_focus_mode: ISSUE_BOARD_FOCUS_MODE_FEATURE, issue_board_focus_mode: ISSUE_BOARD_FOCUS_MODE_FEATURE,
issue_board_milestone: ISSUE_BOARD_MILESTONE_FEATURE, issue_board_milestone: ISSUE_BOARD_MILESTONE_FEATURE,
group_issue_boards: GROUP_ISSUE_BOARDS_FEATURE,
issue_weights: ISSUE_WEIGHTS_FEATURE, issue_weights: ISSUE_WEIGHTS_FEATURE,
jenkins_integration: JENKINS_INTEGRATION_FEATURE, jenkins_integration: JENKINS_INTEGRATION_FEATURE,
jira_dev_panel_integration: JIRA_DEV_PANEL_INTEGRATION_FEATURE, jira_dev_panel_integration: JIRA_DEV_PANEL_INTEGRATION_FEATURE,
...@@ -118,7 +120,8 @@ class License < ActiveRecord::Base ...@@ -118,7 +120,8 @@ class License < ActiveRecord::Base
{ OBJECT_STORAGE_FEATURE => 1 }, { OBJECT_STORAGE_FEATURE => 1 },
{ JIRA_DEV_PANEL_INTEGRATION_FEATURE => 1 }, { JIRA_DEV_PANEL_INTEGRATION_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 }, { SERVICE_DESK_FEATURE => 1 },
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 } { VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 },
{ GROUP_ISSUE_BOARDS_FEATURE => 1 }
].freeze ].freeze
EEU_FEATURES = [ EEU_FEATURES = [
......
...@@ -210,6 +210,14 @@ class Namespace < ActiveRecord::Base ...@@ -210,6 +210,14 @@ class Namespace < ActiveRecord::Base
self.deleted_at = Time.now self.deleted_at = Time.now
end 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 private
def refresh_access_of_projects_invited_groups def refresh_access_of_projects_invited_groups
......
...@@ -1470,6 +1470,14 @@ class Project < ActiveRecord::Base ...@@ -1470,6 +1470,14 @@ class Project < ActiveRecord::Base
end end
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 def full_path_was
File.join(namespace.full_path, previous_changes['path'].first) File.join(namespace.full_path, previous_changes['path'].first)
end end
......
...@@ -24,9 +24,18 @@ class GroupPolicy < BasePolicy ...@@ -24,9 +24,18 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0 with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled } 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 { 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 { admin } .enable :read_group
rule { has_projects } .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 module Boards
class CreateService < BaseService class CreateService < Boards::BaseService
prepend EE::Boards::CreateService prepend EE::Boards::CreateService
def execute def execute
...@@ -9,11 +9,11 @@ module Boards ...@@ -9,11 +9,11 @@ module Boards
private private
def can_create_board? def can_create_board?
project.boards.size == 0 parent.boards.size == 0
end end
def create_board! def create_board!
board = project.boards.create(params) board = parent.boards.create(params)
if board.persisted? if board.persisted?
board.lists.create(list_type: :backlog) board.lists.create(list_type: :backlog)
......
module Boards module Boards
class DestroyService < BaseService class DestroyService < Boards::BaseService
def execute(board) def execute(board)
return false if project.boards.size == 1 return false if parent.boards.size == 1
board.destroy board.destroy
end end
......
module Boards module Boards
module Issues 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 def execute
create_issue(params.merge(label_ids: [list.label_id])) create_issue(params.merge(label_ids: [list.label_id]))
end end
...@@ -8,7 +16,7 @@ module Boards ...@@ -8,7 +16,7 @@ module Boards
private private
def board def board
@board ||= project.boards.find(params.delete(:board_id)) @board ||= parent.boards.find(params.delete(:board_id))
end end
def list def list
......
module Boards module Boards
module Issues module Issues
class ListService < BaseService class ListService < Boards::BaseService
prepend EE::Boards::Issues::ListService
def execute def execute
issues = IssuesFinder.new(current_user, filter_params).execute issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list? issues = without_board_labels(issues) unless movable_list? || closed_list?
...@@ -11,7 +13,7 @@ module Boards ...@@ -11,7 +13,7 @@ module Boards
private private
def board def board
@board ||= project.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
def list def list
...@@ -33,14 +35,13 @@ module Boards ...@@ -33,14 +35,13 @@ module Boards
end end
def filter_params def filter_params
set_project set_parent
set_state set_state
params params
end end
def set_project def set_parent
params[:project_id] = project.id params[:project_id] = parent.id
end end
def set_state def set_state
......
module Boards module Boards
module Issues module Issues
class MoveService < BaseService class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, issue) return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty? return false if issue_params.empty?
update_service.execute(issue) update(issue)
end end
private private
def board def board
@board ||= project.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
def move_between_lists? def move_between_lists?
...@@ -27,8 +29,8 @@ module Boards ...@@ -27,8 +29,8 @@ module Boards
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id]) @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end end
def update_service def update(issue)
::Issues::UpdateService.new(project, current_user, issue_params) ::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
end end
def issue_params def issue_params
...@@ -42,7 +44,7 @@ module Boards ...@@ -42,7 +44,7 @@ module Boards
) )
end end
attrs[:move_between_iids] = move_between_iids if move_between_iids attrs[:move_between_ids] = move_between_ids if move_between_ids
attrs attrs
end end
...@@ -61,16 +63,16 @@ module Boards ...@@ -61,16 +63,16 @@ module Boards
if moving_to_list.movable? if moving_to_list.movable?
moving_from_list.label_id moving_from_list.label_id
else else
Label.on_project_boards(project.id).pluck(:label_id) Label.on_project_boards(parent.id).pluck(:label_id)
end end
Array(label_ids).compact Array(label_ids).compact
end end
def move_between_iids def move_between_ids
return unless params[:move_after_iid] || params[:move_before_iid] 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 end
end end
......
module Boards module Boards
class ListService < BaseService class ListService < Boards::BaseService
prepend EE::Boards::ListService prepend EE::Boards::ListService
def execute def execute
create_board! if project.boards.empty? create_board! if parent.boards.empty?
project.boards parent.boards
end end
private private
def create_board! def create_board!
Boards::CreateService.new(project, current_user).execute Boards::CreateService.new(parent, current_user).execute
end end
end end
end end
module Boards module Boards
module Lists module Lists
class CreateService < BaseService class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
def execute(board) def execute(board)
List.transaction do List.transaction do
label = available_labels.find(params[:label_id]) label = available_labels_for(board).find(params[:label_id])
position = next_position(board) position = next_position(board)
create_list(board, label, position) create_list(board, label, position)
end end
end end
private private
def available_labels def available_labels_for(board)
LabelsFinder.new(current_user, project_id: project.id).execute LabelsFinder.new(current_user, project_id: parent.id).execute
end end
def next_position(board) def next_position(board)
......
module Boards module Boards
module Lists module Lists
class DestroyService < BaseService class DestroyService < Boards::BaseService
def execute(list) def execute(list)
return false unless list.destroyable? return false unless list.destroyable?
......
module Boards module Boards
module Lists module Lists
class GenerateService < BaseService class GenerateService < Boards::BaseService
def execute(board) def execute(board)
return false unless board.lists.movable.empty? return false unless board.lists.movable.empty?
...@@ -15,11 +15,11 @@ module Boards ...@@ -15,11 +15,11 @@ module Boards
def create_list(board, params) def create_list(board, params)
label = find_or_create_label(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 end
def find_or_create_label(params) def find_or_create_label(params)
::Labels::FindOrCreateService.new(current_user, project, params).execute ::Labels::FindOrCreateService.new(current_user, parent, params).execute
end end
def label_params def label_params
......
module Boards module Boards
module Lists module Lists
class ListService < BaseService class ListService < Boards::BaseService
def execute(board) def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
module Boards module Boards
module Lists module Lists
class MoveService < BaseService class MoveService < Boards::BaseService
def execute(list) def execute(list)
@board = list.board @board = list.board
@old_position = list.position @old_position = list.position
......
module Boards module Boards
class UpdateService < BaseService class UpdateService < Boards::BaseService
def execute(board) 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) board.update(params)
end end
......
...@@ -3,7 +3,7 @@ module Issues ...@@ -3,7 +3,7 @@ module Issues
include SpamCheckService include SpamCheckService
def execute(issue) def execute(issue)
handle_move_between_iids(issue) handle_move_between_ids(issue)
filter_spam_check_params filter_spam_check_params
change_issue_duplicate(issue) change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update(issue) move_issue_to_new_project(issue) || update(issue)
...@@ -54,13 +54,13 @@ module Issues ...@@ -54,13 +54,13 @@ module Issues
end end
end end
def handle_move_between_iids(issue) def handle_move_between_ids(issue)
return unless params[:move_between_iids] 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_before = get_issue_if_allowed(before_id) if before_id
issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid issue_after = get_issue_if_allowed(after_id) if after_id
issue.move_between(issue_before, issue_after) issue.move_between(issue_before, issue_after)
end end
...@@ -87,8 +87,8 @@ module Issues ...@@ -87,8 +87,8 @@ module Issues
private private
def get_issue_if_allowed(project, iid) def get_issue_if_allowed(id)
issue = project.issues.find_by(iid: iid) issue = Issue.find(id)
issue if can?(current_user, :update_issue, issue) issue if can?(current_user, :update_issue, issue)
end end
......
...@@ -8,6 +8,12 @@ ...@@ -8,6 +8,12 @@
%span %span
List 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 = nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do = link_to group_labels_path(@group), title: 'Labels' do
%span %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 - 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 - 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{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll .nav-sidebar-inner-scroll
...@@ -39,7 +42,7 @@ ...@@ -39,7 +42,7 @@
%span %span
Contribution Analytics 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 = sidebar_link issues_group_path(@group), title: _('Issues') do
.nav-icon-container .nav-icon-container
= custom_icon('issues') = custom_icon('issues')
...@@ -59,6 +62,12 @@ ...@@ -59,6 +62,12 @@
%span %span
List 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 = nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do = link_to group_labels_path(@group), title: 'Labels' do
%span %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 @@ ...@@ -5,16 +5,21 @@
- breadcrumb_title "Issue Boards" - breadcrumb_title "Issue Boards"
- page_title "Boards" - page_title "Boards"
- add_to_breadcrumbs("Issues", @issues_path)
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search' = webpack_bundle_tag 'filtered_search'
= webpack_bundle_tag 'boards' = 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-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" %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 .hidden-xs.hidden-sm
= render 'shared/issuable/search_bar', type: :boards, board: board = render 'shared/issuable/search_bar', type: :boards, board: board
...@@ -32,7 +37,8 @@ ...@@ -32,7 +37,8 @@
":root-path" => "rootPath", ":root-path" => "rootPath",
":board-id" => "boardId", ":board-id" => "boardId",
":key" => "_uid" } ":key" => "_uid" }
= render "projects/boards/components/sidebar" = render "shared/boards/components/sidebar"
- if @project
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'), %board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project), "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path, "milestone-path" => milestones_filter_dropdown_path,
......
- 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, %boards-selector{ "inline-template" => true,
":current-board" => current_board_json, ":current-board" => current_board_json,
"milestone-path" => project_milestones_path(board.project, :json) } "milestone-path" => milestones_filter_path(milestone_filter_opts) }
.dropdown .dropdown
%button.dropdown-menu-toggle{ "@click" => "loadBoards", %button.dropdown-menu-toggle{ "@click" => "loadBoards",
data: { toggle: "dropdown" } } data: { toggle: "dropdown" } }
...@@ -20,40 +24,40 @@ ...@@ -20,40 +24,40 @@
.dropdown-content{ "v-if" => "currentPage === ''" } .dropdown-content{ "v-if" => "currentPage === ''" }
%ul{ "v-if" => "!loading" } %ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" } %li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{project_boards_path(@project)}/' + board.id" } %a{ ":href" => "'#{board_base_url}/' + board.id" }
{{ board.name }} {{ 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 %li.small
Some of your boards are hidden, activate a license to see them again. Some of your boards are hidden, activate a license to see them again.
.dropdown-loading{ "v-if" => "loading" } .dropdown-loading{ "v-if" => "loading" }
= icon("spin spinner") = icon("spin spinner")
- if can?(current_user, :admin_board, @project) - if can?(current_user, :admin_board, parent)
%board-selector-form{ "inline-template" => true, %board-selector-form{ "inline-template" => true,
":milestone-path" => "milestonePath", ":milestone-path" => "milestonePath",
"v-if" => "currentPage === 'new' || currentPage === 'edit' || currentPage === 'milestone'" } "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'" } .dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p %p
Are you sure you want to delete this board? Are you sure you want to delete this board?
.board-delete-btns.clearfix .board-delete-btns.clearfix
= link_to project_board_path(@project, board), = link_to current_board_path(board),
class: "btn btn-danger pull-left", class: "btn btn-danger pull-left",
method: :delete do method: :delete do
Delete Delete
%button.btn.btn-default.pull-right{ type: "button", %button.btn.btn-default.pull-right{ type: "button",
"@click.stop.prevent" => "showPage('')" } "@click.stop.prevent" => "showPage('')" }
Cancel Cancel
- if can?(current_user, :admin_board, @project) - if can?(current_user, :admin_board, parent)
.dropdown-footer{ "v-if" => "currentPage === ''" } .dropdown-footer{ "v-if" => "currentPage === ''" }
%ul.dropdown-footer-list %ul.dropdown-footer-list
- if @project.feature_available?(:multiple_issue_boards) - if parent.feature_available?(:multiple_issue_boards)
%li %li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('new')" } %a{ "href" => "#", "@click.stop.prevent" => "showPage('new')" }
Create new board Create new board
%li %li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" } %a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name Edit board name
- if @project.feature_available?(:issue_board_milestone, current_user) - if parent.issue_board_milestone_available?(current_user)
%li %li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" } %a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" }
Edit board milestone Edit board milestone
......
...@@ -7,20 +7,26 @@ ...@@ -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 }", ":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" } "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 : "")' } ":title" => '(list.label ? list.label.description : "")' }
{{ list.title }} {{ list.title }}
%span.has-tooltip{ "v-if": "list.type === \"label\"", %span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")', ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" }, 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\") }" } ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" }
{{ list.title }} {{ 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 }' } %span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }} {{ 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", %button.issue-count-badge-add-button.btn.btn-small.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm", "@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"', "v-if" => 'list.type !== "closed"',
...@@ -28,12 +34,6 @@ ...@@ -28,12 +34,6 @@
"title" => "New issue", "title" => "New issue",
data: { placement: "top", container: "body" } } data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse") = 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"', %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list", ":list" => "list",
":issues" => "list.issues", ":issues" => "list.issues",
...@@ -41,7 +41,8 @@ ...@@ -41,7 +41,8 @@
":disabled" => "disabled", ":disabled" => "disabled",
":issue-link-base" => "issueLinkBase", ":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath", ":root-path" => "rootPath",
":groupId" => ((current_board_parent.id if @group) || 'null'),
"ref" => "board-list" } "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-blank-state{ "v-if" => 'list.id == "blank"' }
%board-promotion-state{ "v-if" => 'list.id == "promotion"' } %board-promotion-state{ "v-if" => 'list.id == "promotion"' }
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
%input.form-control{ type: "text", %input.form-control{ type: "text",
id: "board-new-name", id: "board-new-name",
"v-model" => "board.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 }", .dropdown.board-inner-milestone-dropdown{ ":class" => "{ open: milestoneDropdownOpen }",
"v-if" => "currentPage === 'new'" } "v-if" => "currentPage === 'new'" }
%label.label-light{ for: "board-milestone" } %label.label-light{ for: "board-milestone" }
......
...@@ -10,18 +10,19 @@ ...@@ -10,18 +10,19 @@
%br/ %br/
%span %span
= precede "#" do = precede "#" do
{{ issue.id }} {{ issue.iid }}
%a.gutter-toggle.pull-right{ role: "button", %a.gutter-toggle.pull-right{ role: "button",
href: "#", href: "#",
"@click.prevent" => "closeSidebar", "@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" } "aria-label" => "Toggle sidebar" }
= custom_icon("icon_close", size: 15) = custom_icon("icon_close", size: 15)
.js-issuable-update .js-issuable-update
= render "projects/boards/components/sidebar/assignee" = render "shared/boards/components/sidebar/assignee"
= render "projects/boards/components/sidebar/milestone" = render "shared/boards/components/sidebar/milestone"
= render "projects/boards/components/sidebar/due_date" = render "shared/boards/components/sidebar/due_date"
= render "projects/boards/components/sidebar/labels" = render "shared/boards/components/sidebar/labels"
= render "projects/boards/components/sidebar/notifications" = render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue", %remove-btn{ ":issue" => "issue",
":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
":list" => "list", ":list" => "list",
"v-if" => "canRemove" } "v-if" => "canRemove" }
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
%template{ "v-if" => "issue.assignees" } %template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length", %assignee-title{ ":number-of-assignees" => "issue.assignees.length",
":loading" => "loadingAssignees", ":loading" => "loadingAssignees",
":editable" => can?(current_user, :admin_issue, @project) } ":editable" => can_admin_issue? }
%assignees.value{ "root-path" => "#{root_url}", %assignees.value{ "root-path" => "#{root_url}",
":users" => "issue.assignees", ":users" => "issue.assignees",
":editable" => can?(current_user, :admin_issue, @project), ":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" } "@assign-self" => "assignSelf" }
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
.selectbox.hide-collapsed .selectbox.hide-collapsed
%input.js-vue{ type: "hidden", %input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]", name: "issue[assignee_ids][]",
...@@ -20,9 +20,9 @@ ...@@ -20,9 +20,9 @@
":data-username" => "assignee.username" } ":data-username" => "assignee.username" }
.dropdown .dropdown
- dropdown_options = issue_assignees_dropdown_options - 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'] }, %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.id", ":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" } ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
= dropdown_options[:title] = dropdown_options[:title]
= icon("chevron-down") = icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
......
.block.due_date .block.due_date
.title .title
Due date Due date
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value .value
...@@ -10,12 +10,12 @@ ...@@ -10,12 +10,12 @@
No due date No due date
%span.bold{ "v-if" => "issue.dueDate" } %span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }} {{ 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" } %span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
\- \-
%a.js-remove-due-date{ href: "#", role: "button" } %a.js-remove-due-date{ href: "#", role: "button" }
remove due date remove due date
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
.selectbox .selectbox
%input{ type: "hidden", %input{ type: "hidden",
name: "issue[due_date]", name: "issue[due_date]",
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
.dropdown .dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button', %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: { 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 %span.dropdown-toggle-text Due date
= icon('chevron-down') = icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date .dropdown-menu.dropdown-menu-due-date
......
.block.labels .block.labels
.title .title
Labels Labels
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels .value.issuable-show-labels
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
"v-for" => "label in issue.labels" } "v-for" => "label in issue.labels" }
%span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } %span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
{{ label.title }} {{ label.title }}
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
.selectbox .selectbox
%input{ type: "hidden", %input{ type: "hidden",
name: "issue[label_names][]", name: "issue[label_names][]",
...@@ -19,12 +19,19 @@ ...@@ -19,12 +19,19 @@
":value" => "label.id" } ":value" => "label.id" }
.dropdown .dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", %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: { toggle: "dropdown",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" } 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 %span.dropdown-toggle-text
Label Label
= icon('chevron-down') = icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default" = 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" = render partial: "shared/issuable/label_page_create"
.block.milestone .block.milestone
.title .title
Milestone Milestone
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value .value
...@@ -9,17 +9,17 @@ ...@@ -9,17 +9,17 @@
None None
%span.bold.has-tooltip{ "v-if" => "issue.milestone" } %span.bold.has-tooltip{ "v-if" => "issue.milestone" }
{{ issue.milestone.title }} {{ issue.milestone.title }}
- if can?(current_user, :admin_issue, @project) - if can_admin_issue?
.selectbox .selectbox
%input{ type: "hidden", %input{ type: "hidden",
":value" => "issue.milestone.id", ":value" => "issue.milestone.id",
name: "issue[milestone_id]", name: "issue[milestone_id]",
"v-if" => "issue.milestone" } "v-if" => "issue.milestone" }
.dropdown .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-selected" => "milestoneTitle",
":data-issuable-id" => "issue.id", ":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" } ":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
Milestone Milestone
= icon("chevron-down") = icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-menu.dropdown-select.dropdown-menu-selectable
......
- if current_user - 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 %span.issuable-header-text.hide-collapsed.pull-left
Notifications Notifications
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
......
...@@ -8,20 +8,19 @@ ...@@ -8,20 +8,19 @@
- if show_boards_content - if show_boards_content
.issue-board-dropdown-content .issue-board-dropdown-content
%p %p
Create lists from the labels you use in your project. Issues with that Create lists from labels. Issues with that label appear in that list.
label will automatically be added to the list.
= dropdown_filter(filter_placeholder) = dropdown_filter(filter_placeholder)
= dropdown_content = dropdown_content
- if @project && show_footer - if current_board_parent && show_footer
= dropdown_footer do = dropdown_footer do
%ul.dropdown-footer-list %ul.dropdown-footer-list
- if can?(current_user, :admin_label, @project) - if can?(current_user, :admin_label, current_board_parent)
%li %li
%a.dropdown-toggle-page{ href: "#" } %a.dropdown-toggle-page{ href: "#" }
Create new label Create new label
%li %li
= link_to project_labels_path(@project), :"data-is-link" => true do = link_to labels_path, :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project) - if show_create && can?(current_user, :admin_label, current_board_parent)
Manage labels Manage labels
- else - else
View labels View labels
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards && board - if type == :boards && board
#js-multiple-boards-switcher.inline.boards-switcher{ "v-cloak" => true } #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 = 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? - if params[:search].present?
= hidden_field_tag :search, params[:search] = hidden_field_tag :search, params[:search]
...@@ -124,15 +124,19 @@ ...@@ -124,15 +124,19 @@
= icon('times') = icon('times')
.filter-dropdown-container .filter-dropdown-container
- if type == :boards - 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 .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 Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable .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" } = 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" = render partial: "shared/issuable/label_page_create"
= dropdown_loading = dropdown_loading
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } } #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 - elsif type != :boards_modal
= render 'shared/sort_dropdown' = render 'shared/sort_dropdown'
---
title: Add group issue boards
merge_request:
author:
...@@ -89,6 +89,19 @@ Rails.application.routes.draw do ...@@ -89,6 +89,19 @@ Rails.application.routes.draw do
# Notification settings # Notification settings
resources :notification_settings, only: [:create, :update] 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 :import
draw :uploads draw :uploads
draw :explore draw :explore
......
...@@ -62,6 +62,9 @@ scope(path: 'groups/*group_id', ...@@ -62,6 +62,9 @@ scope(path: 'groups/*group_id',
resources :variables, only: [:index, :show, :update, :create, :destroy] resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :billings, only: [:index] resources :billings, only: [:index]
end end
## EE-specific
resources :boards, only: [:index, :show, :create, :update, :destroy]
end end
scope(path: 'groups/*id', scope(path: 'groups/*id',
......
...@@ -381,19 +381,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -381,19 +381,7 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes' get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
resources :boards, only: [:index, :show, :create, :update, :destroy] do resources :boards, only: [:index, :show, :create, :update, :destroy]
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 :todos, only: [:create] 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 ...@@ -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 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| 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "name", default: "Development", null: false t.string "name", default: "Development", null: false
t.integer "milestone_id" t.integer "milestone_id"
t.integer "group_id"
end 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", ["milestone_id"], name: "index_boards_on_milestone_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_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 ...@@ -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 "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", name: "fk_1e9a074a35", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify 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 ...@@ -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. - [Discussions](user/discussions/index.md) Threads, comments, and resolvable discussions in issues, commits, and merge requests.
- [Issues](user/project/issues/index.md) - [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 - **(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. - [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. - [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 EE
module Projects module Boards
module BoardsController module BoardsController
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
before_action :check_multiple_issue_boards_available!, only: [:create] before_action :check_multiple_issue_boards_available!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy] before_action :authorize_admin_board!, only: [:create, :update, :destroy]
...@@ -9,7 +10,7 @@ module EE ...@@ -9,7 +10,7 @@ module EE
end end
def create 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| respond_to do |format|
format.json do format.json do
...@@ -23,7 +24,7 @@ module EE ...@@ -23,7 +24,7 @@ module EE
end end
def update def update
service = ::Boards::UpdateService.new(project, current_user, board_params) service = ::Boards::UpdateService.new(parent, current_user, board_params)
service.execute(@board) service.execute(@board)
...@@ -39,18 +40,18 @@ module EE ...@@ -39,18 +40,18 @@ module EE
end end
def destroy def destroy
service = ::Boards::DestroyService.new(project, current_user) service = ::Boards::DestroyService.new(parent, current_user)
service.execute(@board) service.execute(@board)
respond_to do |format| respond_to do |format|
format.html { redirect_to project_boards_path(@project), status: 302 } format.html { redirect_to boards_path, status: 302 }
end end
end end
private private
def authorize_admin_board! 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 end
def board_params def board_params
...@@ -58,7 +59,19 @@ module EE ...@@ -58,7 +59,19 @@ module EE
end end
def find_board 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 end
def serialize_as_json(resource) 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 EE
module BoardsHelper module BoardsHelper
def parent
@group || @project
end
def board_data def board_data
super.merge(focus_mode_available: @project.feature_available?(:issue_board_focus_mode).to_s, data = {
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) 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 end
end end
...@@ -3,7 +3,7 @@ module EE ...@@ -3,7 +3,7 @@ module EE
def issue_assignees_dropdown_options def issue_assignees_dropdown_options
options = super options = super
if @project.feature_available?(:multiple_issue_assignees) if current_board_parent.feature_available?(:multiple_issue_assignees)
options[:title] = 'Select assignee(s)' options[:title] = 'Select assignee(s)'
options[:data][:'dropdown-header'] = 'Assignee(s)' options[:data][:'dropdown-header'] = 'Assignee(s)'
options[:data].delete(:'max-select') 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 ...@@ -4,10 +4,17 @@ module EE
prepended do prepended do
belongs_to :milestone belongs_to :milestone
belongs_to :group
validates :group, presence: true, unless: :project
end
def project_needed?
!group
end end
def milestone 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 if milestone_id == ::Milestone::Upcoming.id
::Milestone::Upcoming ::Milestone::Upcoming
...@@ -16,6 +23,14 @@ module EE ...@@ -16,6 +23,14 @@ module EE
end end
end end
def parent
@parent ||= group || project
end
def group_board?
group_id.present?
end
def as_json(options = {}) def as_json(options = {})
milestone_attrs = options.fetch(:include, {}) milestone_attrs = options.fetch(:include, {})
.extract!(:milestone) .extract!(:milestone)
......
...@@ -7,6 +7,8 @@ module EE ...@@ -7,6 +7,8 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_many :boards
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
state :ready state :ready
state :started 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 ...@@ -6,6 +6,12 @@ module EE
with_scope :subject with_scope :subject
condition(:ldap_synced) { @subject.ldap_synced? } 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 condition(:can_owners_manage_ldap, scope: :global) do
::Gitlab::CurrentSettings.current_application_settings ::Gitlab::CurrentSettings.current_application_settings
.allow_group_owners_to_manage_ldap .allow_group_owners_to_manage_ldap
......
...@@ -4,7 +4,7 @@ module EE ...@@ -4,7 +4,7 @@ module EE
def can_create_board? def can_create_board?
raise NotImplementedError unless defined?(super) raise NotImplementedError unless defined?(super)
project.feature_available?(:multiple_issue_boards) || super parent.feature_available?(:multiple_issue_boards) || super
end end
end 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 ...@@ -4,7 +4,7 @@ module EE
def execute def execute
raise NotImplementedError unless defined?(super) raise NotImplementedError unless defined?(super)
if project.feature_available?(:multiple_issue_boards, current_user) if parent.multiple_issue_boards_available?(current_user)
super super
else else
super.limit(1) 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 .board-promotion-state
.svg-container.center .svg-container.center
= custom_icon('icon_issue_board') = custom_icon('icon_issue_board')
......
...@@ -26,6 +26,7 @@ module Gitlab ...@@ -26,6 +26,7 @@ module Gitlab
apple-touch-icon.png apple-touch-icon.png
assets assets
autocomplete autocomplete
boards
ci ci
dashboard dashboard
deploy.html deploy.html
...@@ -118,6 +119,7 @@ module Gitlab ...@@ -118,6 +119,7 @@ module Gitlab
analytics analytics
audit_events audit_events
avatar avatar
boards
edit edit
group_members group_members
hooks hooks
......
require 'spec_helper' require 'spec_helper'
describe Projects::Boards::IssuesController do describe Boards::IssuesController do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -162,17 +162,15 @@ describe Projects::Boards::IssuesController do ...@@ -162,17 +162,15 @@ describe Projects::Boards::IssuesController do
def create_issue(user:, board:, list:, title:) def create_issue(user:, board:, list:, title:)
sign_in(user) sign_in(user)
post :create, namespace_id: project.namespace.to_param, post :create, board_id: board.to_param,
project_id: project,
board_id: board.to_param,
list_id: list.to_param, list_id: list.to_param,
issue: { title: title }, issue: { title: title, project_id: project.id },
format: :json format: :json
end end
end end
describe 'PATCH update' do 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 context 'with valid params' do
it 'returns a successful 200 response' do it 'returns a successful 200 response' do
...@@ -202,7 +200,7 @@ describe Projects::Boards::IssuesController do ...@@ -202,7 +200,7 @@ describe Projects::Boards::IssuesController do
end end
it 'returns a not found 404 response for invalid issue id' do 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) expect(response).to have_http_status(404)
end end
...@@ -226,9 +224,9 @@ describe Projects::Boards::IssuesController do ...@@ -226,9 +224,9 @@ describe Projects::Boards::IssuesController do
sign_in(user) sign_in(user)
patch :update, namespace_id: project.namespace.to_param, patch :update, namespace_id: project.namespace.to_param,
project_id: project, project_id: project.id,
board_id: board.to_param, board_id: board.to_param,
id: issue.to_param, id: issue.id,
from_list_id: from_list_id, from_list_id: from_list_id,
to_list_id: to_list_id, to_list_id: to_list_id,
format: :json format: :json
......
require 'spec_helper' require 'spec_helper'
describe Projects::Boards::ListsController do describe Boards::ListsController do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } 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' require 'spec_helper'
describe Board do 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 describe 'milestone' do
subject(:board) { build(:board) } 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' require 'spec_helper'
describe Boards::CreateService do describe Boards::CreateService, services: true do
describe '#execute' do shared_examples 'boards create service' do
let(:project) { create(:project) }
context 'With the feature available' do context 'With the feature available' do
before do before do
stub_licensed_features(multiple_issue_boards: true) stub_licensed_features(multiple_issue_boards: true)
end end
context 'with valid params' do 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 it 'creates a new board' do
expect { service.execute }.to change(project.boards, :count).by(1) expect { service.execute }.to change(parent.boards, :count).by(1)
end end
it 'creates the default lists' do it 'creates the default lists' do
...@@ -26,10 +24,10 @@ describe Boards::CreateService do ...@@ -26,10 +24,10 @@ describe Boards::CreateService do
end end
context 'with invalid params' do 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 it 'does not create a new parent board' do
expect { service.execute }.not_to change(project.boards, :count) expect { service.execute }.not_to change(parent.boards, :count)
end end
it "does not create board's default lists" do it "does not create board's default lists" do
...@@ -40,10 +38,10 @@ describe Boards::CreateService do ...@@ -40,10 +38,10 @@ describe Boards::CreateService do
end end
context 'without params' do 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 it 'creates a new parent board' do
expect { service.execute }.to change(project.boards, :count).by(1) expect { service.execute }.to change(parent.boards, :count).by(1)
end end
it "creates board's default lists" do it "creates board's default lists" do
...@@ -57,11 +55,21 @@ describe Boards::CreateService do ...@@ -57,11 +55,21 @@ describe Boards::CreateService do
it 'skips creating a second board when the feature is not available' do it 'skips creating a second board when the feature is not available' do
stub_licensed_features(multiple_issue_boards: false) 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 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 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 ...@@ -41,5 +41,23 @@ describe 'layouts/nav/sidebar/_group' do
expect(rendered).to have_text 'Contribution Analytics' expect(rendered).to have_text 'Contribution Analytics'
end 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
end end
FactoryGirl.define do FactoryGirl.define do
factory :board do factory :board do
sequence(:name) { |n| "board#{n}" } 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| after(:create) do |board|
board.lists.create(list_type: :closed) board.lists.create(list_type: :closed)
......
...@@ -7,6 +7,7 @@ FactoryGirl.define do ...@@ -7,6 +7,7 @@ FactoryGirl.define do
group nil group nil
project_id nil project_id nil
group_id nil group_id nil
parent nil
end end
trait :active do trait :active do
...@@ -26,6 +27,9 @@ FactoryGirl.define do ...@@ -26,6 +27,9 @@ FactoryGirl.define do
milestone.project = evaluator.project milestone.project = evaluator.project
elsif evaluator.project_id elsif evaluator.project_id
milestone.project_id = 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 else
milestone.project = create(:project) milestone.project = create(:project)
end end
......
...@@ -8,10 +8,15 @@ ...@@ -8,10 +8,15 @@
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"confidential": { "type": "boolean" }, "confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] }, "due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" }, "relative_position": { "type": "integer" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
},
"labels": { "labels": {
"type": "array", "type": "array",
"items": { "items": {
...@@ -34,6 +39,7 @@ ...@@ -34,6 +39,7 @@
"type": "string", "type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
}, },
"type": { "type": "string" },
"title": { "type": "string" }, "title": { "type": "string" },
"priority": { "type": ["integer", "null"] } "priority": { "type": ["integer", "null"] }
}, },
......
...@@ -165,6 +165,27 @@ describe('Api', () => { ...@@ -165,6 +165,27 @@ describe('Api', () => {
done(); 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', () => { describe('groupProjects', () => {
......
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import boardBlankState from '~/boards/components/board_blank_state'; import boardBlankState from '~/boards/components/board_blank_state';
...@@ -12,7 +13,7 @@ describe('Boards blank state', () => { ...@@ -12,7 +13,7 @@ describe('Boards blank state', () => {
const Comp = Vue.extend(boardBlankState); const Comp = Vue.extend(boardBlankState);
gl.issueBoards.BoardsStore.create(); 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) => { spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
if (fail) { if (fail) {
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
/* global listObj */ /* global listObj */
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/models/assignee'; import '~/boards/models/assignee';
...@@ -14,13 +15,13 @@ import '~/boards/stores/boards_store'; ...@@ -14,13 +15,13 @@ import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card'; import boardCard from '~/boards/components/board_card';
import './mock_data'; import './mock_data';
describe('Issue card', () => { describe('Board card', () => {
let vm; let vm;
beforeEach((done) => { beforeEach((done) => {
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.issueBoards.BoardsStore.detail.issue = {}; gl.issueBoards.BoardsStore.detail.issue = {};
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
/* global List */ /* global List */
/* global listObj */ /* global listObj */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import Sortable from 'vendor/Sortable'; import Sortable from 'vendor/Sortable';
...@@ -24,7 +25,7 @@ describe('Board list component', () => { ...@@ -24,7 +25,7 @@ describe('Board list component', () => {
document.body.appendChild(el); document.body.appendChild(el);
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue(); gl.IssueBoardsApp = new Vue();
...@@ -32,6 +33,7 @@ describe('Board list component', () => { ...@@ -32,6 +33,7 @@ describe('Board list component', () => {
const list = new List(listObj); const list = new List(listObj);
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
......
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
/* global List */ /* global List */
/* global listObj */ /* global listObj */
...@@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => { ...@@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => {
const BoardNewIssueComp = Vue.extend(boardNewIssue); const BoardNewIssueComp = Vue.extend(boardNewIssue);
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue(); gl.IssueBoardsApp = new Vue();
......
...@@ -4,10 +4,10 @@ ...@@ -4,10 +4,10 @@
/* global listObj */ /* global listObj */
/* global listObjDuplicate */ /* global listObjDuplicate */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import '~/lib/utils/url_utility'; import '~/lib/utils/url_utility';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/label'; import '~/boards/models/label';
...@@ -20,7 +20,7 @@ import './mock_data'; ...@@ -20,7 +20,7 @@ import './mock_data';
describe('Store', () => { describe('Store', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => { spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => {
...@@ -78,7 +78,7 @@ describe('Store', () => { ...@@ -78,7 +78,7 @@ describe('Store', () => {
it('persists new list', (done) => { it('persists new list', (done) => {
gl.issueBoards.BoardsStore.new({ gl.issueBoards.BoardsStore.new({
title: 'Test', title: 'Test',
type: 'label', list_type: 'label',
label: { label: {
id: 1, id: 1,
title: 'Testing', title: 'Testing',
...@@ -210,6 +210,7 @@ describe('Store', () => { ...@@ -210,6 +210,7 @@ describe('Store', () => {
it('moves issue in list', (done) => { it('moves issue in list', (done) => {
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 2,
iid: 2, iid: 2,
confidential: false, confidential: false,
labels: [], labels: [],
......
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import '~/boards/components/board'; import '~/boards/components/board';
import '~/boards/models/list'; import '~/boards/models/list';
import '../mock_data';
describe('Board component', () => { describe('Board component', () => {
let vm; let vm;
...@@ -13,8 +15,12 @@ describe('Board component', () => { ...@@ -13,8 +15,12 @@ describe('Board component', () => {
el = document.createElement('div'); el = document.createElement('div');
document.body.appendChild(el); document.body.appendChild(el);
// eslint-disable-next-line no-undef gl.boardService = mockBoardService({
gl.boardService = new BoardService('/', '/', 1); boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
vm = new gl.issueBoards.Board({ vm = new gl.issueBoards.Board({
propsData: { propsData: {
......
...@@ -37,6 +37,7 @@ describe('Issue card component', () => { ...@@ -37,6 +37,7 @@ describe('Issue card component', () => {
list = listObj; list = listObj;
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
...@@ -51,6 +52,7 @@ describe('Issue card component', () => { ...@@ -51,6 +52,7 @@ describe('Issue card component', () => {
issue, issue,
issueLinkBase: '/test', issueLinkBase: '/test',
rootPath: '/', rootPath: '/',
groupId: null,
}; };
}, },
components: { components: {
...@@ -60,6 +62,7 @@ describe('Issue card component', () => { ...@@ -60,6 +62,7 @@ describe('Issue card component', () => {
<issue-card <issue-card
:issue="issue" :issue="issue"
:list="list" :list="list"
:group-id="groupId"
:issue-link-base="issueLinkBase" :issue-link-base="issueLinkBase"
:root-path="rootPath"></issue-card> :root-path="rootPath"></issue-card>
`, `,
...@@ -238,7 +241,6 @@ describe('Issue card component', () => { ...@@ -238,7 +241,6 @@ describe('Issue card component', () => {
}); });
describe('labels', () => { describe('labels', () => {
describe('exists', () => {
beforeEach((done) => { beforeEach((done) => {
component.issue.addLabel(label1); component.issue.addLabel(label1);
...@@ -291,12 +293,55 @@ describe('Issue card component', () => { ...@@ -291,12 +293,55 @@ describe('Issue card component', () => {
).toBe(2); ).toBe(2);
expect( expect(
component.$el.textContent, component.$el.textContent,
).not.toContain('closed'); ).not.toContain('Closed');
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(); done();
}) })
.catch(done.fail); .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 */ /* eslint-disable comma-dangle */
/* global BoardService */ /* global BoardService */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/lib/utils/url_utility'; import '~/lib/utils/url_utility';
...@@ -16,11 +17,12 @@ describe('Issue model', () => { ...@@ -16,11 +17,12 @@ describe('Issue model', () => {
let issue; let issue;
beforeEach(() => { beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [{ labels: [{
......
/* eslint-disable comma-dangle */ /* eslint-disable comma-dangle */
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
/* global List */ /* global List */
/* global ListIssue */ /* global ListIssue */
/* global listObj */ /* global listObj */
...@@ -22,7 +23,9 @@ describe('List model', () => { ...@@ -22,7 +23,9 @@ describe('List model', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor); 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(); gl.issueBoards.BoardsStore.create();
list = new List(listObj); list = new List(listObj);
...@@ -92,6 +95,7 @@ describe('List model', () => { ...@@ -92,6 +95,7 @@ describe('List model', () => {
const listDup = new List(listObjDuplicate); const listDup = new List(listObjDuplicate);
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: _.random(10000),
iid: _.random(10000), iid: _.random(10000),
confidential: false, confidential: false,
labels: [list.label, listDup.label], labels: [list.label, listDup.label],
...@@ -118,7 +122,8 @@ describe('List model', () => { ...@@ -118,7 +122,8 @@ describe('List model', () => {
for (let i = 0; i < 30; i += 1) { for (let i = 0; i < 30; i += 1) {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
iid: _.random(10000) + i, id: _.random(10000) + i,
iid: _.random(1000) + i,
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
...@@ -137,7 +142,7 @@ describe('List model', () => { ...@@ -137,7 +142,7 @@ describe('List model', () => {
it('does not increase page number if issue count is less than the page size', () => { it('does not increase page number if issue count is less than the page size', () => {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
...@@ -156,7 +161,7 @@ describe('List model', () => { ...@@ -156,7 +161,7 @@ describe('List model', () => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({ spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
json() { json() {
return { return {
iid: 42, id: 42,
}; };
}, },
})); }));
...@@ -165,14 +170,14 @@ describe('List model', () => { ...@@ -165,14 +170,14 @@ describe('List model', () => {
it('adds new issue to top of list', (done) => { it('adds new issue to top of list', (done) => {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
})); }));
const dummyIssue = new ListIssue({ const dummyIssue = new ListIssue({
title: 'new issue', title: 'new issue',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
......
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global boardObj */ /* global boardObj */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import milestoneSelect from '~/boards/components/milestone_select'; import milestoneSelect from '~/boards/components/milestone_select';
...@@ -16,7 +17,7 @@ describe('Milestone select component', () => { ...@@ -16,7 +17,7 @@ describe('Milestone select component', () => {
const MilestoneComp = Vue.extend(milestoneSelect); const MilestoneComp = Vue.extend(milestoneSelect);
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => { selectMilestoneSpy = jasmine.createSpy('selectMilestone').and.callFake((milestone) => {
......
/* global BoardService */
/* eslint-disable comma-dangle, no-unused-vars, quote-props */ /* eslint-disable comma-dangle, no-unused-vars, quote-props */
const boardObj = { const boardObj = {
id: 1, id: 1,
...@@ -33,15 +34,15 @@ const listObjDuplicate = { ...@@ -33,15 +34,15 @@ const listObjDuplicate = {
const BoardsMockData = { const BoardsMockData = {
'GET': { 'GET': {
'/test/issue-boards/board/1/lists{/id}/issues': { '/test/boards/1{/id}/issues': {
issues: [{ issues: [{
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
assignees: [], assignees: [],
}], }],
size: 1
}, },
'/test/issue-boards/milestones.json': [{ '/test/issue-boards/milestones.json': [{
id: 1, id: 1,
...@@ -49,7 +50,7 @@ const BoardsMockData = { ...@@ -49,7 +50,7 @@ const BoardsMockData = {
}], }],
}, },
'POST': { 'POST': {
'/test/issue-boards/board/1/lists{/id}': listObj '/test/boards/1{/id}': listObj
}, },
'PUT': { 'PUT': {
'/test/issue-boards/board/1/lists{/id}': {} '/test/issue-boards/board/1/lists{/id}': {}
...@@ -67,8 +68,23 @@ const boardsMockInterceptor = (request, next) => { ...@@ -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.boardObj = boardObj;
window.listObj = listObj; window.listObj = listObj;
window.listObjDuplicate = listObjDuplicate; window.listObjDuplicate = listObjDuplicate;
window.BoardsMockData = BoardsMockData; window.BoardsMockData = BoardsMockData;
window.boardsMockInterceptor = boardsMockInterceptor; window.boardsMockInterceptor = boardsMockInterceptor;
window.mockBoardService = mockBoardService;
...@@ -18,6 +18,7 @@ describe('Modal store', () => { ...@@ -18,6 +18,7 @@ describe('Modal store', () => {
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
...@@ -25,6 +26,7 @@ describe('Modal store', () => { ...@@ -25,6 +26,7 @@ describe('Modal store', () => {
}); });
issue2 = new ListIssue({ issue2 = new ListIssue({
title: 'Testing', title: 'Testing',
id: 2,
iid: 2, iid: 2,
confidential: false, confidential: false,
labels: [], labels: [],
......
...@@ -10,6 +10,7 @@ describe('Dropdown User', () => { ...@@ -10,6 +10,7 @@ describe('Dropdown User', () => {
beforeEach(() => { beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {}); spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {}); spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
dropdownUser = new gl.DropdownUser({ dropdownUser = new gl.DropdownUser({
...@@ -38,6 +39,7 @@ describe('Dropdown User', () => { ...@@ -38,6 +39,7 @@ describe('Dropdown User', () => {
beforeEach(() => { beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {}); spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getGroupId').and.callFake(() => {});
}); });
it('should return endpoint', () => { it('should return endpoint', () => {
......
...@@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do ...@@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do
let(:label) { create(:label, project: project, name: 'in-progress') } let(:label) { create(:label, project: project, name: 'in-progress') }
let!(:list) { create(:list, board: board, label: label, position: 0) } 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 before do
project.team << [user, :developer] project.team << [user, :developer]
......
...@@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do ...@@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do
issue.move_to_end && issue.save! issue.move_to_end && issue.save!
end 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) described_class.new(project, user, params).execute(issue)
......
...@@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do ...@@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do
issue.save issue.save
end end
opts[:move_between_iids] = [issue1.iid, issue2.iid] opts[:move_between_ids] = [issue1.id, issue2.id]
update_issue(opts) 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