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;
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath); if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
return $.ajax({ 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"
:list="list" :group-id="groupId"
v-if="list.type !== 'closed' && showIssueForm"/> :list="list"
</transition> v-if="list.type !== 'closed' && showIssueForm"/>
<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,43 +81,53 @@ export default { ...@@ -59,43 +81,53 @@ 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">
<form @submit="submit($event)"> <div class="card">
<div class="flash-container" <form @submit="submit($event)">
v-if="error"> <div class="flash-container"
<div class="flash-alert"> v-if="error">
An error occured. Please try again. <div class="flash-alert">
An error occured. Please try again.
</div>
</div>
<label class="label-light"
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<project-select
v-if="groupId"
:groupId="groupId"
/>
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="disabled"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div> </div>
</div> </form>
<label class="label-light" </div>
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="title === ''"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div>
</form>
</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,10 +34,18 @@ class Groups::LabelsController < Groups::ApplicationController ...@@ -28,10 +34,18 @@ 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)
if @label.valid? respond_to do |format|
redirect_to group_labels_path(@group) format.html do
else if @label.valid?
render :new redirect_to group_labels_path(@group)
else
render :new
end
end
format.json do
render json: LabelSerializer.new.represent_appearance(@label)
end
end end
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,19 +137,21 @@ module SearchHelper ...@@ -137,19 +137,21 @@ module SearchHelper
end end
def search_filter_input_options(type) def search_filter_input_options(type)
opts = { opts =
id: "filtered-search-#{type}", {
placeholder: 'Search or filter results...', id: "filtered-search-#{type}",
data: { placeholder: 'Search or filter results...',
'username-params' => @users.to_json(only: [:id, :username]) data: {
'username-params' => @users.to_json(only: [:id, :username])
}
} }
}
if @project.present? if @project.present?
opts[:data]['project-id'] = @project.id opts[:data]['project-id'] = @project.id
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,11 +37,12 @@ ...@@ -32,11 +37,12 @@
":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"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'), - if @project
"new-issue-path" => new_project_issue_path(@project), %board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"milestone-path" => milestones_filter_dropdown_path, "new-issue-path" => new_project_issue_path(@project),
"label-path" => labels_filter_path, "milestone-path" => milestones_filter_dropdown_path,
":issue-link-base" => "issueLinkBase", "label-path" => labels_filter_path,
":root-path" => "rootPath", ":issue-link-base" => "issueLinkBase",
":project-id" => @project.try(:id) } ":root-path" => "rootPath",
":project-id" => @project.try(:id) }
- parent = board.parent
- milestone_filter_opts = { format: :json }
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
%boards-selector{ "inline-template" => true, %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
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } } - if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } }
#js-toggle-focus-btn.prepend-left-10
- elsif type != :boards_modal - 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)
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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