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

Merge branch 'issue_928_group_boards' into 'master'

Group issue boards

Closes #928

See merge request !2467
parents 91c41c7c c3b7a738
......@@ -6,7 +6,8 @@ const Api = {
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json?simple=true',
labelsPath: '/:namespace_path/:project_path/labels',
projectLabelsPath: '/:namespace_path/:project_path/labels',
groupLabelsPath: '/groups/:namespace_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key',
......@@ -74,9 +75,16 @@ const Api = {
},
newLabel(namespacePath, projectPath, data, callback) {
const url = Api.buildUrl(Api.labelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
let url;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
}
return $.ajax({
url,
type: 'POST',
......
......@@ -11,6 +11,7 @@ import './models/issue';
import './models/label';
import './models/list';
import './models/milestone';
import './models/project';
import './models/assignee';
import './stores/boards_store';
import './stores/modal_store';
......@@ -58,7 +59,8 @@ $(() => {
data: {
state: Store.state,
loading: true,
endpoint: $boardApp.dataset.endpoint,
boardsEndpoint: $boardApp.dataset.boardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId,
disabled: $boardApp.dataset.disabled === 'true',
issueLinkBase: $boardApp.dataset.issueLinkBase,
......@@ -84,8 +86,13 @@ $(() => {
Store.updateFiltersUrl(true);
}
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
Store.rootPath = this.endpoint;
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
});
Store.rootPath = this.boardsEndpoint;
this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]);
this.filterManager.setup();
......@@ -149,7 +156,7 @@ $(() => {
focusModeAvailable: gl.utils.convertPermissionToBoolean(
$boardApp.dataset.focusModeAvailable,
),
canAdminList: gl.utils.convertPermissionToBoolean(
canAdminList: this.$options.el && gl.utils.convertPermissionToBoolean(
this.$options.el.dataset.canAdminList,
),
};
......@@ -161,6 +168,9 @@ $(() => {
},
computed: {
disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
......@@ -188,14 +198,6 @@ $(() => {
this.toggleModal(true);
}
},
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
mounted() {
this.updateTooltip();
......@@ -214,6 +216,30 @@ $(() => {
@click="openModal">
Add issues
</button>
</div>
`,
});
gl.IssueBoardsToggleFocusBtn = new Vue({
el: document.getElementById('js-toggle-focus-btn'),
data: {
modal: ModalStore.store,
store: Store.state,
isFullscreen: false,
focusModeAvailable: gl.utils.convertPermissionToBoolean($boardApp.dataset.focusModeAvailable),
},
methods: {
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
template: `
<div class="board-extra-actions">
<a
href="#"
class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
......
......@@ -16,6 +16,7 @@ export default {
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath"
:update-filters="true" />
</li>
......@@ -30,6 +31,7 @@ export default {
disabled: Boolean,
index: Number,
rootPath: String,
groupId: Number,
},
data() {
return {
......
......@@ -9,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore;
export default {
name: 'BoardList',
props: {
groupId: {
type: Number,
required: false,
default: 0,
},
disabled: {
type: Boolean,
required: true,
......@@ -77,7 +82,7 @@ export default {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
if (!this.list.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
this.loadNextPage();
}
},
......@@ -160,17 +165,19 @@ export default {
template: `
<div class="board-list-component">
<div
key="loading"
class="board-list-loading text-center"
aria-label="Loading issues"
v-if="loading">
<loading-icon />
</div>
<transition name="slide-down">
<board-new-issue
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
</transition>
<board-new-issue
key="newIssue"
:group-id="groupId"
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
key="list"
class="board-list"
v-show="!loading"
ref="list"
......@@ -183,6 +190,7 @@ export default {
:list="list"
:issue="issue"
:issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath"
:disabled="disabled"
:key="issue.id" />
......
/* global ListIssue */
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
const Store = gl.issueBoards.BoardsStore;
export default {
name: 'BoardNewIssue',
props: {
list: Object,
groupId: {
type: Number,
required: false,
default: 0,
},
list: {
type: Object,
required: true,
},
},
data() {
return {
title: '',
error: false,
selectedProject: {},
};
},
components: {
'project-select': ProjectSelect,
},
computed: {
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
},
methods: {
submit(e) {
e.preventDefault();
......@@ -27,6 +48,7 @@ export default {
labels,
subscribed: true,
assignees: [],
project_id: this.selectedProject.id,
});
if (Store.state.currentBoard) {
......@@ -59,43 +81,53 @@ export default {
this.title = '';
eventHub.$emit(`hide-issue-form-${this.list.id}`);
},
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
template: `
<div class="card board-new-issue-form">
<form @submit="submit($event)">
<div class="flash-container"
v-if="error">
<div class="flash-alert">
An error occured. Please try again.
<div class="board-new-issue-form">
<div class="card">
<form @submit="submit($event)">
<div class="flash-container"
v-if="error">
<div class="flash-alert">
An error occured. Please try again.
</div>
</div>
<label class="label-light"
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<project-select
v-if="groupId"
:groupId="groupId"
/>
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="disabled"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div>
</div>
<label class="label-light"
:for="list.id + '-title'">
Title
</label>
<input class="form-control"
type="text"
v-model="title"
ref="input"
autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
type="submit"
:disabled="title === ''"
ref="submit-button">
Submit issue
</button>
<button class="btn btn-default pull-right"
type="button"
@click="cancel">
Cancel
</button>
</div>
</form>
</form>
</div>
</div>
`,
};
......@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false,
default: false,
},
groupId: {
type: Number,
required: false,
},
},
data() {
return {
......@@ -64,10 +68,19 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
return `${this.issueLinkBase}/${this.issue.id}`;
let baseUrl = this.issueLinkBase;
if (this.groupId && this.issue.project) {
baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path);
}
return `${baseUrl}/${this.issue.iid}`;
},
issueId() {
return `#${this.issue.id}`;
if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
......@@ -98,6 +111,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
},
showLabel(label) {
if (!label.id) return false;
if (this.groupId && label.type === 'ProjectLabel') return false;
return true;
},
filterByLabel(label, e) {
......@@ -143,7 +157,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issue.id"
v-if="issueId"
>
{{ issueId }}
</span>
......
......@@ -29,7 +29,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
const issueIds = selectedIssues.map(issue => issue.id);
// Post the data to the backend
gl.boardService.bulkUpdate(issueIds, {
......
......@@ -27,7 +27,7 @@ gl.issueBoards.newListDropdownInit = () => {
$this.glDropdown({
data(term, callback) {
$.get($this.attr('data-labels'))
$.get($this.attr('data-list-labels-path'))
.then((resp) => {
callback(resp);
});
......
<template>
<div>
<label class="label-light prepend-top-10">
Project
</label>
<div ref="projectsDropdown" class="dropdown">
<button
class="dropdown-menu-toggle wide"
type="button"
data-toggle="dropdown"
aria-expanded="false">
{{ selectedProjectName }}
<i class="fa fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div class="dropdown-title">
<span>Projects</span>
<button aria-label="Close" type="button" class="dropdown-title-button dropdown-menu-close">
<i aria-hidden="true" data-hidden="true" class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
class="dropdown-input-field"
type="search"
placeholder="Search projects">
<i aria-hidden="true" data-hidden="true" class="fa fa-search dropdown-input-search"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</template>
<script>
/* global ListIssue */
import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import Api from '../../api';
export default {
name: 'BoardProjectSelect',
props: {
groupId: {
type: Number,
required: true,
default: 0,
},
},
data() {
return {
loading: true,
selectedProject: {},
};
},
components: {
loadingIcon,
},
computed: {
selectedProjectName() {
return this.selectedProject.name || 'Select a project';
},
},
mounted() {
$(this.$refs.projectsDropdown).glDropdown({
filterable: true,
filterRemote: true,
search: {
fields: ['name_with_namespace'],
},
clicked: ({ $el, e }) => {
e.preventDefault();
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
selectable: true,
data: (term, callback) => {
this.loading = true;
return Api.groupProjects(this.groupId, term, (projects) => {
this.loading = false;
callback(projects);
});
},
renderRow(project) {
return `
<li>
<a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}">
${_.escape(project.name)}
</a>
</li>
`;
},
text: project => project.name,
});
},
};
</script>
......@@ -18,22 +18,38 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate.replace(':project_path', this.issue.project.path);
},
},
methods: {
removeIssue() {
const issue = this.issue;
const lists = issue.getLists();
const labelIds = lists.map(list => list.label.id);
const listLabelIds = lists.map(list => list.label.id);
let labelIds = this.issue.labels
.map(label => label.id)
.filter(id => !listLabelIds.includes(id));
if (labelIds.length === 0) {
labelIds = [''];
}
const data = {
remove_label_ids: labelIds,
issue: {
label_ids: labelIds,
},
};
if (Store.state.currentBoard.milestone_id) {
data.milestone_id = -1;
data.issue.milestone_id = -1;
}
// Post the remove data
gl.boardService.bulkUpdate([issue.globalId], data).catch(() => {
Vue.http.patch(this.updateUrl, data).catch(() => {
new Flash('Failed to remove issue from board, please try again.', 'alert');
lists.forEach((list) => {
......
......@@ -4,11 +4,12 @@
/* global ListAssignee */
import Vue from 'vue';
import IssueProject from './project';
class ListIssue {
constructor (obj, defaultAvatar) {
this.globalId = obj.id;
this.id = obj.iid;
this.id = obj.id;
this.iid = obj.iid;
this.title = obj.title;
this.confidential = obj.confidential;
this.dueDate = obj.due_date;
......@@ -18,6 +19,11 @@ class ListIssue {
this.selected = false;
this.position = obj.relative_position || Infinity;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
if (obj.project) {
this.project = new IssueProject(obj.project);
}
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
......@@ -88,7 +94,8 @@ class ListIssue {
data.issue.label_ids = [''];
}
return Vue.http.patch(url, data);
const projectPath = this.project ? this.project.path : '';
return Vue.http.patch(url.replace(':project_path', projectPath), data);
}
}
......
......@@ -4,6 +4,7 @@ class ListLabel {
constructor (obj) {
this.id = obj.id;
this.title = obj.title;
this.type = obj.type;
this.color = obj.color;
this.textColor = obj.text_color;
this.description = obj.description;
......
......@@ -110,12 +110,14 @@ class List {
return gl.boardService.newIssue(this.id, issue)
.then(resp => resp.json())
.then((data) => {
issue.id = data.iid;
issue.id = data.id;
issue.iid = data.iid;
issue.milestone = data.milestone;
issue.project = data.project;
if (this.issuesSize > 1) {
const moveBeforeIid = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
const moveBeforeId = this.issues[1].id;
gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
}
});
}
......@@ -127,19 +129,19 @@ class List {
}
addIssue (issue, listFrom, newIndex) {
let moveBeforeIid = null;
let moveAfterIid = null;
let moveBeforeId = null;
let moveAfterId = null;
if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) {
moveBeforeIid = this.issues[newIndex - 1].id;
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex + 1]) {
moveAfterIid = this.issues[newIndex + 1].id;
moveAfterId = this.issues[newIndex + 1].id;
}
} else {
this.issues.push(issue);
......@@ -152,30 +154,30 @@ class List {
if (listFrom) {
this.issuesSize += 1;
this.updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid);
this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
}
}
}
moveIssue (issue, oldIndex, newIndex, moveBeforeIid, moveAfterIid) {
moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid)
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid)
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
findIssue (id) {
return this.issues.filter(issue => issue.id === id)[0];
return this.issues.find(issue => issue.id === id);
}
removeIssue (removeIssue) {
......
export default class IssueProject {
constructor(obj) {
this.id = obj.id;
this.path = obj.path;
}
}
......@@ -3,21 +3,21 @@
import Vue from 'vue';
class BoardService {
constructor (root, bulkUpdatePath, boardId) {
this.boards = Vue.resource(`${root}{/id}.json`, {}, {
constructor ({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: {
method: 'GET',
url: `${root}/${boardId}/issues.json`
url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
}
});
this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, {
this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
generate: {
method: 'POST',
url: `${root}/${boardId}/lists/generate.json`
url: `${listsEndpoint}/generate.json`
}
});
this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, {
this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: {
method: 'POST',
url: bulkUpdatePath,
......@@ -71,12 +71,12 @@ class BoardService {
return this.issues.get(data);
}
moveIssue (id, from_list_id = null, to_list_id = null, move_before_iid = null, move_after_iid = null) {
moveIssue (id, from_list_id = null, to_list_id = null, move_before_id = null, move_after_id = null) {
return this.issue.update({ id }, {
from_list_id,
to_list_id,
move_before_iid,
move_after_iid,
move_before_id,
move_after_id,
});
}
......
......@@ -15,8 +15,8 @@ class DropdownUser extends gl.FilteredSearchDropdown {
params: {
per_page: 20,
active: true,
project_id: this.getProjectId(),
current_user: true,
...this.projectOrGroupId(),
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
......@@ -47,10 +47,27 @@ class DropdownUser extends gl.FilteredSearchDropdown {
super.renderContent(forceShowList);
}
getGroupId() {
return this.input.getAttribute('data-group-id');
}
getProjectId() {
return this.input.getAttribute('data-project-id');
}
projectOrGroupId() {
const projectId = this.getProjectId();
const groupId = this.getGroupId();
if (groupId) {
return {
group_id: groupId,
};
}
return {
project_id: projectId,
};
}
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
......
......@@ -2,13 +2,14 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', tokenizer, page) {
constructor(baseEndpoint = '', tokenizer, page, isGroup) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
this.groupsOnly = page === 'boards' && isGroup;
if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE;
......@@ -47,7 +48,7 @@ class FilteredSearchDropdownManager {
reference: null,
gl: 'DropdownNonUser',
extraArguments: {
endpoint: `${this.baseEndpoint}/milestones.json`,
endpoint: `${this.baseEndpoint}/milestones.json${this.groupsOnly ? '?only_group_milestones=true' : ''}`,
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
......@@ -56,7 +57,7 @@ class FilteredSearchDropdownManager {
reference: null,
gl: 'DropdownNonUser',
extraArguments: {
endpoint: `${this.baseEndpoint}/labels.json`,
endpoint: `${this.baseEndpoint}/labels.json${this.groupsOnly ? '?only_group_labels=true' : ''}`,
symbol: '~',
preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
},
......
......@@ -63,7 +63,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.dropdownManager = new gl.FilteredSearchDropdownManager(
this.filteredSearchInput.getAttribute('data-base-endpoint') || '',
this.tokenizer,
this.page,
Boolean(this.filteredSearchInput.getAttribute('data-group-id')),
);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
......
......@@ -18,14 +18,20 @@ class Subscription {
}
button.classList.add('disabled');
// hack to allow this to work with the issue boards Vue object
const isBoardsPage = document.querySelector('html').classList.contains('issue-boards-page');
const isSubscribed = buttonSpan.innerHTML.trim().toLowerCase() !== 'subscribe';
const toggleActionUrl = this.containerElm.dataset.url;
let toggleActionUrl = this.containerElm.dataset.url;
if (isBoardsPage) {
toggleActionUrl = toggleActionUrl.replace(':project_path', gl.issueBoards.BoardsStore.detail.issue.project.path);
}
$.post(toggleActionUrl, () => {
button.classList.remove('disabled');
// hack to allow this to work with the issue boards Vue object
if (document.querySelector('html').classList.contains('issue-boards-page')) {
if (isBoardsPage) {
gl.issueBoards.boardStoreIssueSet(
'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed,
......
......@@ -183,7 +183,7 @@
width: auto;
top: 100%;
left: 0;
z-index: 200;
z-index: 300;
min-width: 240px;
max-width: 500px;
margin-top: 2px;
......
......@@ -147,13 +147,12 @@
}
.board-title {
position: initial;
padding: 0;
border-bottom: 0;
> span {
display: block;
transform: rotate(90deg) translate(25px, 0);
transform: rotate(90deg) translate(35px, 10px);
}
}
......@@ -181,11 +180,18 @@
}
.board-header {
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
position: relative;
&.has-border {
&.has-border::before {
border-top: 3px solid;
border-color: inherit;
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
content: '';
position: absolute;
width: calc(100% + 2px);
top: 0;
left: 0;
margin-top: -1px;
margin-right: -1px;
margin-left: -1px;
......@@ -206,12 +212,16 @@
}
.board-title {
position: relative;
margin: 0;
padding: $gl-padding;
padding-bottom: ($gl-padding + 3px);
padding: 12px $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
display: flex;
align-items: center;
}
.board-title-text {
margin-right: auto;
}
.board-delete {
......@@ -259,43 +269,10 @@
}
}
.slide-down-enter {
transform: translateY(-100%);
}
.slide-down-enter-active {
transition: transform $fade-in-duration;
+ .board-list {
transform: translateY(-136px);
transition: none;
}
}
.slide-down-enter-to {
+ .board-list {
transform: translateY(0);
transition: transform $fade-in-duration ease;
}
}
.slide-down-leave {
transform: translateY(0);
}
.slide-down-leave-active {
transition: all $fade-in-duration;
transform: translateY(-136px);
+ .board-list {
transition: transform $fade-in-duration ease;
transform: translateY(-136px);
}
}
.board-list-component {
height: calc(100% - 49px);
overflow: hidden;
position: relative;
}
.board-list {
......@@ -466,7 +443,7 @@
}
.board-new-issue-form {
z-index: 1;
z-index: 4;
margin: 5px;
}
......
module Boards
class ApplicationController < ::ApplicationController
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def board
@board ||= Board.find(params[:board_id])
end
def board_parent
@board_parent ||= board.parent
end
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
end
module Boards
class IssuesController < Boards::ApplicationController
prepend EE::BoardsResponses
prepend EE::Boards::IssuesController
include BoardsResponses
before_action :authorize_read_issue, only: [:index]
before_action :authorize_create_issue, only: [:create]
before_action :authorize_update_issue, only: [:update]
skip_before_action :authenticate_user!, only: [:index]
def index
issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20)
make_sure_position_is_set(issues) unless Gitlab::Geo.secondary?
render json: {
issues: serialize_as_json(issues.preload(:project)),
size: issues.total_count
}
end
def create
service = Boards::Issues::CreateService.new(board_parent, project, current_user, issue_params)
issue = service.execute
if issue.valid?
render json: serialize_as_json(issue)
else
render json: issue.errors, status: :unprocessable_entity
end
end
def update
service = Boards::Issues::MoveService.new(board_parent, current_user, move_params)
if service.execute(issue)
head :ok
else
head :unprocessable_entity
end
end
private
def make_sure_position_is_set(issues)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
end
end
def issue
@issue ||= issues_finder.execute.find(params[:id])
end
def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
def issues_finder
IssuesFinder.new(current_user, project_id: board_parent.id)
end
def project
board_parent
end
def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id)
end
def issue_params
params.require(:issue)
.permit(:title, :milestone_id, :project_id)
.merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
end
def serialize_as_json(resource)
resource.as_json(
labels: true,
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
end
end
module Boards
class ListsController < Boards::ApplicationController
prepend EE::BoardsResponses
include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index]
def index
lists = Boards::Lists::ListService.new(board.parent, current_user).execute(board)
render json: serialize_as_json(lists)
end
def create
list = Boards::Lists::CreateService.new(board.parent, current_user, list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
list = board.lists.movable.find(params[:id])
service = Boards::Lists::MoveService.new(board_parent, current_user, move_params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def destroy
list = board.lists.destroyable.find(params[:id])
service = Boards::Lists::DestroyService.new(board_parent, current_user)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def generate
service = Boards::Lists::GenerateService.new(board_parent, current_user)
if service.execute(board)
render json: serialize_as_json(board.lists.movable)
else
head :unprocessable_entity
end
end
private
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position)
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
end
end
end
module BoardsResponses
def authorize_read_list
authorize_action_for!(board.parent, :read_list)
end
def authorize_read_issue
authorize_action_for!(board.parent, :read_issue)
end
def authorize_update_issue
authorize_action_for!(issue, :admin_issue)
end
def authorize_create_issue
authorize_action_for!(project, :admin_issue)
end
def authorize_admin_list
authorize_action_for!(board.parent, :admin_list)
end
def authorize_action_for!(resource, ability)
return render_403 unless can?(current_user, ability, resource)
end
def respond_with_boards
respond_with(@boards)
end
def respond_with_board
respond_with(@board)
end
def respond_with(resource)
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(resource)
end
end
end
end
......@@ -14,7 +14,13 @@ class Groups::LabelsController < Groups::ApplicationController
end
format.json do
available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
available_labels =
if params[:only_group_labels]
group.labels
else
LabelsFinder.new(current_user, group_id: @group.id).execute
end
render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
......@@ -28,10 +34,18 @@ class Groups::LabelsController < Groups::ApplicationController
def create
@label = Labels::CreateService.new(label_params).execute(group: group)
if @label.valid?
redirect_to group_labels_path(@group)
else
render :new
respond_to do |format|
format.html do
if @label.valid?
redirect_to group_labels_path(@group)
else
render :new
end
end
format.json do
render json: LabelSerializer.new.represent_appearance(@label)
end
end
end
......
......@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
render json: milestones.map { |m| m.for_display.slice(:title, :name) }
render json: milestones.map { |m| m.for_display.slice(:title, :name, :id) }
end
end
end
......@@ -78,7 +78,13 @@ class Groups::MilestonesController < Groups::ApplicationController
search_params = params.merge(group_ids: group.id)
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
legacy_milestones =
if params[:only_group_milestones]
[]
else
GroupMilestone.build_collection(group, group_projects, params)
end
milestones + legacy_milestones
end
......
module Projects
module Boards
class ApplicationController < Projects::ApplicationController
respond_to :json
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def record_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
end
end
end
module Projects
module Boards
class IssuesController < Boards::ApplicationController
before_action :authorize_read_issue!, only: [:index]
before_action :authorize_create_issue!, only: [:create]
before_action :authorize_update_issue!, only: [:update]
def index
issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20)
make_sure_position_is_set(issues) unless Gitlab::Geo.secondary?
render json: {
issues: serialize_as_json(issues),
size: issues.total_count
}
end
def create
service = ::Boards::Issues::CreateService.new(project, current_user, issue_params)
issue = service.execute
if issue.valid?
render json: serialize_as_json(issue)
else
render json: issue.errors, status: :unprocessable_entity
end
end
def update
service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
if service.execute(issue)
head :ok
else
head :unprocessable_entity
end
end
private
def make_sure_position_is_set(issues)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
end
end
def issue
@issue ||=
IssuesFinder.new(current_user, project_id: project.id)
.execute
.where(iid: params[:id])
.first!
end
def authorize_read_issue!
return render_403 unless can?(current_user, :read_issue, project)
end
def authorize_create_issue!
return render_403 unless can?(current_user, :admin_issue, project)
end
def authorize_update_issue!
return render_403 unless can?(current_user, :update_issue, issue)
end
def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_iid, :move_after_iid)
end
def issue_params
params.require(:issue).permit(:title, :milestone_id).merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
end
def serialize_as_json(resource)
resource.as_json(
labels: true,
only: [:id, :iid, :title, :confidential, :due_date, :relative_position],
include: {
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
milestone: { only: [:id, :title] }
},
user: current_user
)
end
end
end
end
module Projects
module Boards
class ListsController < Boards::ApplicationController
before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
before_action :authorize_read_list!, only: [:index]
def index
lists = ::Boards::Lists::ListService.new(project, current_user).execute(board)
render json: serialize_as_json(lists)
end
def create
list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
else
render json: list.errors, status: :unprocessable_entity
end
end
def update
list = board.lists.movable.find(params[:id])
service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def destroy
list = board.lists.destroyable.find(params[:id])
service = ::Boards::Lists::DestroyService.new(project, current_user)
if service.execute(list)
head :ok
else
head :unprocessable_entity
end
end
def generate
service = ::Boards::Lists::GenerateService.new(project, current_user)
if service.execute(board)
render json: serialize_as_json(board.lists.movable)
else
head :unprocessable_entity
end
end
private
def authorize_admin_list!
return render_403 unless can?(current_user, :admin_list, project)
end
def authorize_read_list!
return render_403 unless can?(current_user, :read_list, project)
end
def board
@board ||= project.boards.find(params[:board_id])
end
def list_params
params.require(:list).permit(:label_id)
end
def move_params
params.require(:list).permit(:position)
end
def serialize_as_json(resource)
resource.as_json(
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
end
end
end
end
class Projects::BoardsController < Projects::ApplicationController
prepend EE::Projects::BoardsController
prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses
include IssuableCollections
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
def index
@boards = ::Boards::ListService.new(project, current_user).execute
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(@boards)
end
end
@boards = Boards::ListService.new(project, current_user).execute
respond_with_boards
end
def show
@board = project.boards.find(params[:id])
respond_to do |format|
format.html
format.json do
render json: serialize_as_json(@board)
end
end
respond_with_board
end
private
def assign_endpoint_vars
@boards_endpoint = project_boards_url(project)
@bulk_issues_path = bulk_update_project_issues_path(project)
@namespace_path = project.namespace.full_path
@labels_endpoint = project_labels_path(project)
end
def authorize_read_board!
return access_denied! unless can?(current_user, :read_board, project)
end
......
module BoardsHelper
prepend EE::BoardsHelper
def board_data
board = @board || @boards.first
def board
@board ||= @board || @boards.first
end
def board_data
{
endpoint: project_boards_path(@project),
boards_endpoint: @boards_endpoint,
lists_endpoint: board_lists_url(board),
board_id: board.id,
board_milestone_title: board&.milestone&.title,
disabled: "#{!can?(current_user, :admin_list, @project)}",
issue_link_base: project_issues_path(@project),
disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
issue_link_base: build_issue_link_base,
root_path: root_path,
bulk_update_path: bulk_update_project_issues_path(@project),
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar)
}
end
def build_issue_link_base
project_issues_path(@project)
end
def current_board_json
board = @board || @boards.first
......@@ -26,4 +32,51 @@ module BoardsHelper
}
)
end
def board_base_url
project_boards_path(@project)
end
def multiple_boards_available?
current_board_parent.multiple_issue_boards_available?(current_user)
end
def current_board_path(board)
@current_board_path ||= project_board_path(current_board_parent, board)
end
def current_board_parent
@current_board_parent ||= @project
end
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def board_list_data
{
toggle: "dropdown",
list_labels_path: labels_filter_path(true),
labels: labels_filter_path(true),
labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path,
project_path: @project&.try(:path)
}
end
def board_sidebar_user_data
dropdown_options = issue_assignees_dropdown_options
{
toggle: 'dropdown',
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
project_id: @project&.try(:id),
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
'max-select': dropdown_options[:data][:'max-select']
}
end
end
......@@ -29,7 +29,7 @@ module FormHelper
first_user: current_user&.username,
null_user: true,
current_user: true,
project_id: @project.id,
project_id: @project&.id,
field_name: "issue[assignee_ids][]",
default_label: 'Unassigned',
'max-select': 1,
......
......@@ -369,6 +369,14 @@ module IssuablesHelper
end
end
def labels_path
if @project
project_labels_path(@project)
elsif @group
group_labels_path(@group)
end
end
def issuable_sidebar_options(issuable, can_edit_issuable)
{
endpoint: "#{issuable_json_path(issuable)}?basic=true",
......
......@@ -121,13 +121,14 @@ module LabelsHelper
end
end
def labels_filter_path
return group_labels_path(@group, :json) if @group
def labels_filter_path(only_group_labels = false)
project = @target_project || @project
if project
project_labels_path(project, :json)
elsif @group
options = { only_group_labels: only_group_labels } if only_group_labels
group_labels_path(@group, :json, options)
else
dashboard_labels_path(:json)
end
......
......@@ -137,19 +137,21 @@ module SearchHelper
end
def search_filter_input_options(type)
opts = {
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'username-params' => @users.to_json(only: [:id, :username])
opts =
{
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'username-params' => @users.to_json(only: [:id, :username])
}
}
}
if @project.present?
opts[:data]['project-id'] = @project.id
opts[:data]['base-endpoint'] = project_path(@project)
else
# Group context
opts[:data]['group-id'] = @group.id
opts[:data]['base-endpoint'] = group_canonical_path(@group)
end
......
......@@ -5,7 +5,13 @@ class Board < ActiveRecord::Base
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :name, :project, presence: true
validates :name, presence: true
validates :project, presence: true, if: :project_needed?
def project_needed?
true
end
def backlog_list
lists.merge(List.backlog).take
......
......@@ -10,8 +10,12 @@ module RelativePositioning
after_save :save_positionable_neighbours
end
def project_ids
[project.id]
end
def max_relative_position
self.class.in_projects(project.id).maximum(:relative_position)
self.class.in_projects(project_ids).maximum(:relative_position)
end
def prev_relative_position
......@@ -19,7 +23,7 @@ module RelativePositioning
if self.relative_position
prev_pos = self.class
.in_projects(project.id)
.in_projects(project_ids)
.where('relative_position < ?', self.relative_position)
.maximum(:relative_position)
end
......@@ -32,7 +36,7 @@ module RelativePositioning
if self.relative_position
next_pos = self.class
.in_projects(project.id)
.in_projects(project_ids)
.where('relative_position > ?', self.relative_position)
.minimum(:relative_position)
end
......@@ -59,7 +63,7 @@ module RelativePositioning
pos_after = before.next_relative_position
if before.shift_after?
issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_after)
issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_after)
issue_to_move.move_after
@positionable_neighbours = [issue_to_move]
......@@ -74,7 +78,7 @@ module RelativePositioning
pos_before = after.prev_relative_position
if after.shift_before?
issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_before)
issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_before)
issue_to_move.move_before
@positionable_neighbours = [issue_to_move]
......
......@@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
prepend EE::Issue
prepend EE::RelativePositioning
include InternalId
include Issuable
......
class Label < ActiveRecord::Base
# EE specific
prepend EE::Label
include CacheMarkdownField
include Referable
include Subscribable
......@@ -34,7 +37,8 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) }
scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) }
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project)
joins(:priorities)
......@@ -172,6 +176,7 @@ class Label < ActiveRecord::Base
def as_json(options = {})
super(options).tap do |json|
json[:type] = self.try(:type)
json[:priority] = priority(options[:project]) if options.key?(:project)
end
end
......
......@@ -18,6 +18,7 @@ class License < ActiveRecord::Base
ISSUABLE_DEFAULT_TEMPLATES_FEATURE = 'GitLab_IssuableDefaultTemplates'.freeze
ISSUE_BOARD_FOCUS_MODE_FEATURE = 'GitLab_IssueBoardFocusMode'.freeze
ISSUE_BOARD_MILESTONE_FEATURE = 'GitLab_IssueBoardMilestone'.freeze
GROUP_ISSUE_BOARDS_FEATURE = 'GitLab_GroupIssueBoards'.freeze
ISSUE_WEIGHTS_FEATURE = 'GitLab_IssueWeights'.freeze
JENKINS_INTEGRATION_FEATURE = 'GitLab_JenkinsIntegration'.freeze
JIRA_DEV_PANEL_INTEGRATION_FEATURE = 'GitLab_JiraDevelopmentPanelIntegration'.freeze
......@@ -62,6 +63,7 @@ class License < ActiveRecord::Base
issuable_default_templates: ISSUABLE_DEFAULT_TEMPLATES_FEATURE,
issue_board_focus_mode: ISSUE_BOARD_FOCUS_MODE_FEATURE,
issue_board_milestone: ISSUE_BOARD_MILESTONE_FEATURE,
group_issue_boards: GROUP_ISSUE_BOARDS_FEATURE,
issue_weights: ISSUE_WEIGHTS_FEATURE,
jenkins_integration: JENKINS_INTEGRATION_FEATURE,
jira_dev_panel_integration: JIRA_DEV_PANEL_INTEGRATION_FEATURE,
......@@ -118,7 +120,8 @@ class License < ActiveRecord::Base
{ OBJECT_STORAGE_FEATURE => 1 },
{ JIRA_DEV_PANEL_INTEGRATION_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 },
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 }
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 },
{ GROUP_ISSUE_BOARDS_FEATURE => 1 }
].freeze
EEU_FEATURES = [
......
......@@ -210,6 +210,14 @@ class Namespace < ActiveRecord::Base
self.deleted_at = Time.now
end
def multiple_issue_boards_available?(user = nil)
feature_available?(:multiple_issue_boards)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone)
end
private
def refresh_access_of_projects_invited_groups
......
......@@ -1470,6 +1470,14 @@ class Project < ActiveRecord::Base
end
end
def multiple_issue_boards_available?(user)
feature_available?(:multiple_issue_boards, user)
end
def issue_board_milestone_available?(user = nil)
feature_available?(:issue_board_milestone, user)
end
def full_path_was
File.join(namespace.full_path, previous_changes['path'].first)
end
......
......@@ -24,9 +24,18 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
rule { public_group } .enable :read_group
rule { public_group }.policy do
enable :read_group
enable :read_list
end
rule { logged_in_viewable }.enable :read_group
rule { guest } .enable :read_group
rule { guest }.policy do
enable :read_group
enable :read_list
end
rule { admin } .enable :read_group
rule { has_projects } .enable :read_group
......
module Boards
class BaseService < ::BaseService
# Parent can either a group or a project
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
end
end
module Boards
class CreateService < BaseService
class CreateService < Boards::BaseService
prepend EE::Boards::CreateService
def execute
......@@ -9,11 +9,11 @@ module Boards
private
def can_create_board?
project.boards.size == 0
parent.boards.size == 0
end
def create_board!
board = project.boards.create(params)
board = parent.boards.create(params)
if board.persisted?
board.lists.create(list_type: :backlog)
......
module Boards
class DestroyService < BaseService
class DestroyService < Boards::BaseService
def execute(board)
return false if project.boards.size == 1
return false if parent.boards.size == 1
board.destroy
end
......
module Boards
module Issues
class CreateService < BaseService
class CreateService < Boards::BaseService
attr_accessor :project
def initialize(parent, project, user, params = {})
@project = project
super(parent, user, params)
end
def execute
create_issue(params.merge(label_ids: [list.label_id]))
end
......@@ -8,7 +16,7 @@ module Boards
private
def board
@board ||= project.boards.find(params.delete(:board_id))
@board ||= parent.boards.find(params.delete(:board_id))
end
def list
......
module Boards
module Issues
class ListService < BaseService
class ListService < Boards::BaseService
prepend EE::Boards::Issues::ListService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
......@@ -11,7 +13,7 @@ module Boards
private
def board
@board ||= project.boards.find(params[:board_id])
@board ||= parent.boards.find(params[:board_id])
end
def list
......@@ -33,14 +35,13 @@ module Boards
end
def filter_params
set_project
set_parent
set_state
params
end
def set_project
params[:project_id] = project.id
def set_parent
params[:project_id] = parent.id
end
def set_state
......
module Boards
module Issues
class MoveService < BaseService
class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty?
update_service.execute(issue)
update(issue)
end
private
def board
@board ||= project.boards.find(params[:board_id])
@board ||= parent.boards.find(params[:board_id])
end
def move_between_lists?
......@@ -27,8 +29,8 @@ module Boards
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end
def update_service
::Issues::UpdateService.new(project, current_user, issue_params)
def update(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
end
def issue_params
......@@ -42,7 +44,7 @@ module Boards
)
end
attrs[:move_between_iids] = move_between_iids if move_between_iids
attrs[:move_between_ids] = move_between_ids if move_between_ids
attrs
end
......@@ -61,16 +63,16 @@ module Boards
if moving_to_list.movable?
moving_from_list.label_id
else
Label.on_project_boards(project.id).pluck(:label_id)
Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
end
def move_between_iids
return unless params[:move_after_iid] || params[:move_before_iid]
def move_between_ids
return unless params[:move_after_id] || params[:move_before_id]
[params[:move_after_iid], params[:move_before_iid]]
[params[:move_after_id], params[:move_before_id]]
end
end
end
......
module Boards
class ListService < BaseService
class ListService < Boards::BaseService
prepend EE::Boards::ListService
def execute
create_board! if project.boards.empty?
project.boards
create_board! if parent.boards.empty?
parent.boards
end
private
def create_board!
Boards::CreateService.new(project, current_user).execute
Boards::CreateService.new(parent, current_user).execute
end
end
end
module Boards
module Lists
class CreateService < BaseService
class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
def execute(board)
List.transaction do
label = available_labels.find(params[:label_id])
label = available_labels_for(board).find(params[:label_id])
position = next_position(board)
create_list(board, label, position)
end
end
private
def available_labels
LabelsFinder.new(current_user, project_id: project.id).execute
def available_labels_for(board)
LabelsFinder.new(current_user, project_id: parent.id).execute
end
def next_position(board)
......
module Boards
module Lists
class DestroyService < BaseService
class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?
......
module Boards
module Lists
class GenerateService < BaseService
class GenerateService < Boards::BaseService
def execute(board)
return false unless board.lists.movable.empty?
......@@ -15,11 +15,11 @@ module Boards
def create_list(board, params)
label = find_or_create_label(params)
Lists::CreateService.new(project, current_user, label_id: label.id).execute(board)
Lists::CreateService.new(parent, current_user, label_id: label.id).execute(board)
end
def find_or_create_label(params)
::Labels::FindOrCreateService.new(current_user, project, params).execute
::Labels::FindOrCreateService.new(current_user, parent, params).execute
end
def label_params
......
module Boards
module Lists
class ListService < BaseService
class ListService < Boards::BaseService
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
module Boards
module Lists
class MoveService < BaseService
class MoveService < Boards::BaseService
def execute(list)
@board = list.board
@old_position = list.position
......
module Boards
class UpdateService < BaseService
class UpdateService < Boards::BaseService
def execute(board)
params.delete(:milestone_id) unless project.feature_available?(:issue_board_milestone)
params.delete(:milestone_id) unless parent.feature_available?(:issue_board_milestone)
board.update(params)
end
......
......@@ -3,7 +3,7 @@ module Issues
include SpamCheckService
def execute(issue)
handle_move_between_iids(issue)
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update(issue)
......@@ -54,13 +54,13 @@ module Issues
end
end
def handle_move_between_iids(issue)
return unless params[:move_between_iids]
def handle_move_between_ids(issue)
return unless params[:move_between_ids]
after_iid, before_iid = params.delete(:move_between_iids)
after_id, before_id = params.delete(:move_between_ids)
issue_before = get_issue_if_allowed(issue.project, before_iid) if before_iid
issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid
issue_before = get_issue_if_allowed(before_id) if before_id
issue_after = get_issue_if_allowed(after_id) if after_id
issue.move_between(issue_before, issue_after)
end
......@@ -87,8 +87,8 @@ module Issues
private
def get_issue_if_allowed(project, iid)
issue = project.issues.find_by(iid: iid)
def get_issue_if_allowed(id)
issue = Issue.find(id)
issue if can?(current_user, :update_issue, issue)
end
......
......@@ -8,6 +8,12 @@
%span
List
- if @group.feature_available?(:group_issue_boards)
= nav_link(path: 'groups#boards') do
= link_to group_boards_path(@group), title: 'Boards' do
%span
Boards
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do
%span
......
= render "shared/boards/show", board: @boards.first
= render "shared/boards/show", board: @board
- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index']
- if @group.feature_available?(:group_issue_boards)
- issues_sub_menu_items.push('boards#index')
.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
......@@ -39,7 +42,7 @@
%span
Contribution Analytics
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
= nav_link(path: issues_sub_menu_items) do
= sidebar_link issues_group_path(@group), title: _('Issues') do
.nav-icon-container
= custom_icon('issues')
......@@ -59,6 +62,12 @@
%span
List
- if @group.feature_available?(:group_issue_boards)
= nav_link(path: 'boards#index') do
= link_to group_boards_path(@group), title: 'Boards' do
%span
Boards
= nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do
%span
......
= render "show", board: @boards.first
= render "shared/boards/show", board: @boards.first
= render "show", board: @board
= render "shared/boards/show", board: @board
......@@ -5,16 +5,21 @@
- breadcrumb_title "Issue Boards"
- page_title "Boards"
- add_to_breadcrumbs("Issues", @issues_path)
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
= webpack_bundle_tag 'boards'
%script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board"
%script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
%script#js-board-promotion{ type: "text/x-template" }= render "shared/promotions/promote_issue_board"
= render "projects/issues/head"
- if @group
= render "groups/head_issues"
- else
= render "projects/issues/head"
.hidden-xs.hidden-sm
= render 'shared/issuable/search_bar', type: :boards, board: board
......@@ -32,11 +37,12 @@
":root-path" => "rootPath",
":board-id" => "boardId",
":key" => "_uid" }
= render "projects/boards/components/sidebar"
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
"label-path" => labels_filter_path,
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":project-id" => @project.try(:id) }
= render "shared/boards/components/sidebar"
- if @project
%board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
"label-path" => labels_filter_path,
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":project-id" => @project.try(:id) }
- parent = board.parent
- milestone_filter_opts = { format: :json }
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
%boards-selector{ "inline-template" => true,
":current-board" => current_board_json,
"milestone-path" => project_milestones_path(board.project, :json) }
"milestone-path" => milestones_filter_path(milestone_filter_opts) }
.dropdown
%button.dropdown-menu-toggle{ "@click" => "loadBoards",
data: { toggle: "dropdown" } }
......@@ -20,40 +24,40 @@
.dropdown-content{ "v-if" => "currentPage === ''" }
%ul{ "v-if" => "!loading" }
%li{ "v-for" => "board in boards" }
%a{ ":href" => "'#{project_boards_path(@project)}/' + board.id" }
%a{ ":href" => "'#{board_base_url}/' + board.id" }
{{ board.name }}
- if !@project.feature_available?(:multiple_issue_boards) && @project.boards.size > 1
- if !multiple_boards_available? && current_board_parent.boards.size > 1
%li.small
Some of your boards are hidden, activate a license to see them again.
.dropdown-loading{ "v-if" => "loading" }
= icon("spin spinner")
- if can?(current_user, :admin_board, @project)
- if can?(current_user, :admin_board, parent)
%board-selector-form{ "inline-template" => true,
":milestone-path" => "milestonePath",
"v-if" => "currentPage === 'new' || currentPage === 'edit' || currentPage === 'milestone'" }
= render "projects/boards/components/form"
= render "shared/boards/components/form"
.dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p
Are you sure you want to delete this board?
.board-delete-btns.clearfix
= link_to project_board_path(@project, board),
= link_to current_board_path(board),
class: "btn btn-danger pull-left",
method: :delete do
Delete
%button.btn.btn-default.pull-right{ type: "button",
"@click.stop.prevent" => "showPage('')" }
Cancel
- if can?(current_user, :admin_board, @project)
- if can?(current_user, :admin_board, parent)
.dropdown-footer{ "v-if" => "currentPage === ''" }
%ul.dropdown-footer-list
- if @project.feature_available?(:multiple_issue_boards)
- if parent.feature_available?(:multiple_issue_boards)
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('new')" }
Create new board
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name
- if @project.feature_available?(:issue_board_milestone, current_user)
- if parent.issue_board_milestone_available?(current_user)
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" }
Edit board milestone
......
......@@ -7,20 +7,26 @@
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }",
"aria-hidden": "true" }
%span.has-tooltip{ "v-if": "list.type !== \"label\"",
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
":title" => '(list.label ? list.label.description : "")' }
{{ list.title }}
%span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" },
class: "label color-label title",
class: " label color-label title board-title-text",
":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" }
{{ list.title }}
.issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
- if can?(current_user, :admin_list, current_board_parent)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.append-right-10{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
%span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_issue, @project)
- if can?(current_user, :admin_list, current_board_parent)
%button.issue-count-badge-add-button.btn.btn-small.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
......@@ -28,12 +34,6 @@
"title" => "New issue",
data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse")
- if can?(current_user, :admin_list, @project)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list",
":issues" => "list.issues",
......@@ -41,7 +41,8 @@
":disabled" => "disabled",
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
":groupId" => ((current_board_parent.id if @group) || 'null'),
"ref" => "board-list" }
- if can?(current_user, :admin_list, @project)
- if can?(current_user, :admin_list, current_board_parent)
%board-blank-state{ "v-if" => 'list.id == "blank"' }
%board-promotion-state{ "v-if" => 'list.id == "promotion"' }
......@@ -10,7 +10,7 @@
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
- if @project.feature_available?(:issue_board_milestone, current_user)
- if current_board_parent.issue_board_milestone_available?(current_user)
.dropdown.board-inner-milestone-dropdown{ ":class" => "{ open: milestoneDropdownOpen }",
"v-if" => "currentPage === 'new'" }
%label.label-light{ for: "board-milestone" }
......
......@@ -10,18 +10,19 @@
%br/
%span
= precede "#" do
{{ issue.id }}
{{ issue.iid }}
%a.gutter-toggle.pull-right{ role: "button",
href: "#",
"@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" }
= custom_icon("icon_close", size: 15)
.js-issuable-update
= render "projects/boards/components/sidebar/assignee"
= render "projects/boards/components/sidebar/milestone"
= render "projects/boards/components/sidebar/due_date"
= render "projects/boards/components/sidebar/labels"
= render "projects/boards/components/sidebar/notifications"
= render "shared/boards/components/sidebar/assignee"
= render "shared/boards/components/sidebar/milestone"
= render "shared/boards/components/sidebar/due_date"
= render "shared/boards/components/sidebar/labels"
= render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
":list" => "list",
"v-if" => "canRemove" }
......@@ -2,13 +2,13 @@
%template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length",
":loading" => "loadingAssignees",
":editable" => can?(current_user, :admin_issue, @project) }
":editable" => can_admin_issue? }
%assignees.value{ "root-path" => "#{root_url}",
":users" => "issue.assignees",
":editable" => can?(current_user, :admin_issue, @project),
":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" }
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox.hide-collapsed
%input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]",
......@@ -20,9 +20,9 @@
":data-username" => "assignee.username" }
.dropdown
- dropdown_options = issue_assignees_dropdown_options
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: { toggle: 'dropdown', field_name: 'issue[assignee_ids][]', first_user: current_user&.username, current_user: 'true', project_id: @project.id, null_user: 'true', multi_select: 'true', 'dropdown-header': dropdown_options[:data][:'dropdown-header'], 'max-select': dropdown_options[:data][:'max-select'] },
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
= dropdown_options[:title]
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
......
.block.due_date
.title
Due date
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
......@@ -10,12 +10,12 @@
No due date
%span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }}
- if can?(current_user, :admin_issue, @project)
- if can?(current_user, :admin_issue, current_board_parent)
%span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
remove due date
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
name: "issue[due_date]",
......@@ -23,7 +23,7 @@
.dropdown
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" },
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
......
.block.labels
.title
Labels
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels
......@@ -11,7 +11,7 @@
"v-for" => "label in issue.labels" }
%span.label.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
{{ label.title }}
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
name: "issue[label_names][]",
......@@ -19,12 +19,19 @@
":value" => "label.id" }
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: project_labels_path(@project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) },
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
data: { toggle: "dropdown",
field_name: "issue[label_names][]",
show_no: "true",
show_any: "true",
project_id: @project&.try(:id),
labels: labels_filter_path(false),
namespace_path: @project.try(:namespace).try(:full_path),
project_path: @project.try(:path) },
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
%span.dropdown-toggle-text
Label
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default"
- if can? current_user, :admin_label, @project and @project
- if can?(current_user, :admin_label, current_board_parent)
= render partial: "shared/issuable/label_page_create"
.block.milestone
.title
Milestone
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
= icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
......@@ -9,17 +9,17 @@
None
%span.bold.has-tooltip{ "v-if" => "issue.milestone" }
{{ issue.milestone.title }}
- if can?(current_user, :admin_issue, @project)
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
":value" => "issue.milestone.id",
name: "issue[milestone_id]",
"v-if" => "issue.milestone" }
.dropdown
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), ability_name: "issue", use_id: "true", default_no: "true" },
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
":data-selected" => "milestoneTitle",
":data-issuable-id" => "issue.id",
":data-issue-update" => "'#{project_issues_path(@project)}/' + issue.id + '.json'" }
":data-issuable-id" => "issue.iid",
":data-issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'" }
Milestone
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
......
- if current_user
.block.light.subscription{ ":data-url" => "'#{project_issues_path(@project)}/' + issue.id + '/toggle_subscription'" }
.block.light.subscription{ ":data-url" => "'#{build_issue_link_base}/' + issue.iid + '/toggle_subscription'" }
%span.issuable-header-text.hide-collapsed.pull-left
Notifications
%button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" }
......
......@@ -8,20 +8,19 @@
- if show_boards_content
.issue-board-dropdown-content
%p
Create lists from the labels you use in your project. Issues with that
label will automatically be added to the list.
Create lists from labels. Issues with that label appear in that list.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
- if current_board_parent && show_footer
= dropdown_footer do
%ul.dropdown-footer-list
- if can?(current_user, :admin_label, @project)
- if can?(current_user, :admin_label, current_board_parent)
%li
%a.dropdown-toggle-page{ href: "#" }
Create new label
%li
= link_to project_labels_path(@project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
= link_to labels_path, :"data-is-link" => true do
- if show_create && can?(current_user, :admin_label, current_board_parent)
Manage labels
- else
View labels
......
......@@ -7,7 +7,7 @@
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards && board
#js-multiple-boards-switcher.inline.boards-switcher{ "v-cloak" => true }
= render "projects/boards/switcher", board: board
= render "shared/boards/switcher", board: board
= form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
......@@ -124,15 +124,19 @@
= icon('times')
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, @project)
- if can?(current_user, :admin_list, board.parent)
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) } }
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, @project)
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } }
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project).to_s } }
#js-toggle-focus-btn.prepend-left-10
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
---
title: Add group issue boards
merge_request:
author:
......@@ -89,6 +89,19 @@ Rails.application.routes.draw do
# Notification settings
resources :notification_settings, only: [:create, :update]
# Boards resources shared between group and projects
resources :boards do
resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
collection do
post :generate
end
resources :issues, only: [:index, :create, :update]
end
resources :issues, module: :boards, only: [:index, :update]
end
draw :import
draw :uploads
draw :explore
......
......@@ -62,6 +62,9 @@ scope(path: 'groups/*group_id',
resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :billings, only: [:index]
end
## EE-specific
resources :boards, only: [:index, :show, :create, :update, :destroy]
end
scope(path: 'groups/*id',
......
......@@ -381,19 +381,7 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
resources :boards, only: [:index, :show, :create, :update, :destroy] do
scope module: :boards do
resources :issues, only: [:index, :update]
resources :lists, only: [:index, :create, :update, :destroy] do
collection do
post :generate
end
resources :issues, only: [:index, :create]
end
end
end
resources :boards, only: [:index, :show, :create, :update, :destroy]
resources :todos, only: [:create]
......
class AddGroupIdToBoards < ActiveRecord::Migration
DOWNTIME = false
def up
change_column_null :boards, :project_id, true
add_column :boards, :group_id, :integer
end
def down
# We cannot rollback project_id not null constraint if there are records
# with null values.
execute "DELETE from boards WHERE project_id IS NULL"
remove_column :boards, :group_id
change_column :boards, :project_id, :integer, null: false
end
end
class AddGroupBoardsIndexes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_concurrent_foreign_key :boards, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :boards, :group_id
end
def down
remove_foreign_key :boards, column: :group_id
remove_concurrent_index :boards, :group_id
end
end
......@@ -214,13 +214,15 @@ ActiveRecord::Schema.define(version: 20170906160132) do
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
create_table "boards", force: :cascade do |t|
t.integer "project_id", null: false
t.integer "project_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name", default: "Development", null: false
t.integer "milestone_id"
t.integer "group_id"
end
add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
add_index "boards", ["milestone_id"], name: "index_boards_on_milestone_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
......@@ -2036,6 +2038,7 @@ ActiveRecord::Schema.define(version: 20170906160132) do
add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", name: "fk_1e9a074a35", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify
......
......@@ -93,7 +93,8 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [Discussions](user/discussions/index.md) Threads, comments, and resolvable discussions in issues, commits, and merge requests.
- [Issues](user/project/issues/index.md)
- [Issue Board](user/project/issue_board.md)
- [Project issue Board](user/project/issue_board.md)
- **(EEP)** [Group issue boards](user/group/issue_boards.md)
- **(EES/EEP)** [Related Issues](user/project/issues/related_issues.md): create a relationship between issues
- [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests.
- [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles.
......
# Group issue board
> Introduced in GitLab 10.0.
Group issue boards help users manage teams in organizations
that have a team-centered or product-centered approach which spans
many projects.
![Group Issue Board](img/group_issue_board.png)
The design and functionality are the same as [project boards](../project/issue_board.md) with some small differences:
* In a group board all issues within group projects will be displayed
on backlog or closed lists. Users don't need to filter them by project on search bar.
* Group boards can only have [group milestones](../project/milestones/index.md#creating-a-group-milestone) associated with them.
* Only group [labels](../project/labels.md) can be used to create lists inside each board.
>**Note:**
Only issues in immediate child projects of the group are available in the group board. Issues in further descendant subgroups are not shown in the group board.
module EE
module Projects
module Boards
module BoardsController
extend ActiveSupport::Concern
prepended do
before_action :check_multiple_issue_boards_available!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy]
......@@ -9,7 +10,7 @@ module EE
end
def create
board = ::Boards::CreateService.new(project, current_user, board_params).execute
board = ::Boards::CreateService.new(parent, current_user, board_params).execute
respond_to do |format|
format.json do
......@@ -23,7 +24,7 @@ module EE
end
def update
service = ::Boards::UpdateService.new(project, current_user, board_params)
service = ::Boards::UpdateService.new(parent, current_user, board_params)
service.execute(@board)
......@@ -39,18 +40,18 @@ module EE
end
def destroy
service = ::Boards::DestroyService.new(project, current_user)
service = ::Boards::DestroyService.new(parent, current_user)
service.execute(@board)
respond_to do |format|
format.html { redirect_to project_boards_path(@project), status: 302 }
format.html { redirect_to boards_path, status: 302 }
end
end
private
def authorize_admin_board!
return render_404 unless can?(current_user, :admin_board, project)
return render_404 unless can?(current_user, :admin_board, parent)
end
def board_params
......@@ -58,7 +59,19 @@ module EE
end
def find_board
@board = project.boards.find(params[:id])
@board = parent.boards.find(params[:id])
end
def parent
@parent ||= @project || @group
end
def boards_path
if @group
group_boards_path(parent)
else
project_boards_path(parent)
end
end
def serialize_as_json(resource)
......
module EE
module Boards
module IssuesController
def issues_finder
return super unless board.group_board?
IssuesFinder.new(current_user, group_id: board_parent.id)
end
def project
@project ||= begin
if board.group_board?
::Project.find(issue_params[:project_id])
else
super
end
end
end
end
end
end
module EE
module BoardsResponses
# Shared authorizations between projects and groups which
# have different policies on EE.
def authorize_read_list
ability = board.group_board? ? :read_group : :read_list
authorize_action_for!(board.parent, ability)
end
def authorize_read_issue
ability = board.group_board? ? :read_group : :read_issue
authorize_action_for!(board.parent, ability)
end
end
end
class Groups::BoardsController < Groups::ApplicationController
prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses
before_action :check_group_issue_boards_available!
before_action :assign_endpoint_vars
def index
@boards = Boards::ListService.new(group, current_user).execute
respond_with_boards
end
def show
@board = group.boards.find(params[:id])
respond_with_board
end
def assign_endpoint_vars
@boards_endpoint = group_boards_url(group)
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
end
module EE
module BoardsHelper
def parent
@group || @project
end
def board_data
super.merge(focus_mode_available: @project.feature_available?(:issue_board_focus_mode).to_s,
show_promotion: (show_promotions? && (!@project.feature_available?(:multiple_issue_boards) || !@project.feature_available?(:issue_board_milestone) || !@project.feature_available?(:issue_board_focus_mode))).to_s)
data = {
board_milestone_title: board&.milestone&.title,
focus_mode_available: parent.feature_available?(:issue_board_focus_mode).to_s,
show_promotion: (@project && show_promotions? && (!@project.feature_available?(:multiple_issue_boards) || !@project.feature_available?(:issue_board_milestone) || !@project.feature_available?(:issue_board_focus_mode))).to_s
}
super.merge(data)
end
def build_issue_link_base
return super unless @board.group_board?
"/#{@board.group.path}/:project_path/issues"
end
def board_base_url
if board.group_board?
group_boards_url(@group)
else
super
end
end
def current_board_path(board)
@current_board_path ||= begin
if board.group_board?
group_board_path(current_board_parent, board)
else
super(board)
end
end
end
def current_board_parent
@current_board_parent ||= @group || super
end
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def board_list_data
super.merge(group_path: @group&.path)
end
def board_sidebar_user_data
super.merge(group_id: @group&.id)
end
end
end
......@@ -3,7 +3,7 @@ module EE
def issue_assignees_dropdown_options
options = super
if @project.feature_available?(:multiple_issue_assignees)
if current_board_parent.feature_available?(:multiple_issue_assignees)
options[:title] = 'Select assignee(s)'
options[:data][:'dropdown-header'] = 'Assignee(s)'
options[:data].delete(:'max-select')
......
module EE
# Issue position on list boards should be relative to all group projects
module RelativePositioning
extend ActiveSupport::Concern
def board_group
@group ||= project.group
end
def has_group_boards?
board_group && board_group.boards.any?
end
def project_ids
return super unless has_group_boards?
board_group.projects.select(:id)
end
end
end
......@@ -4,10 +4,17 @@ module EE
prepended do
belongs_to :milestone
belongs_to :group
validates :group, presence: true, unless: :project
end
def project_needed?
!group
end
def milestone
return nil unless project.feature_available?(:issue_board_milestone)
return nil unless parent.feature_available?(:issue_board_milestone)
if milestone_id == ::Milestone::Upcoming.id
::Milestone::Upcoming
......@@ -16,6 +23,14 @@ module EE
end
end
def parent
@parent ||= group || project
end
def group_board?
group_id.present?
end
def as_json(options = {})
milestone_attrs = options.fetch(:include, {})
.extract!(:milestone)
......
......@@ -7,6 +7,8 @@ module EE
extend ActiveSupport::Concern
included do
has_many :boards
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
state :ready
state :started
......
module EE
module Label
extend ActiveSupport::Concern
prepended do
scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
end
end
end
......@@ -6,6 +6,12 @@ module EE
with_scope :subject
condition(:ldap_synced) { @subject.ldap_synced? }
rule { reporter }.policy do
enable :admin_list
enable :admin_board
enable :admin_issue
end
condition(:can_owners_manage_ldap, scope: :global) do
::Gitlab::CurrentSettings.current_application_settings
.allow_group_owners_to_manage_ldap
......
......@@ -4,7 +4,7 @@ module EE
def can_create_board?
raise NotImplementedError unless defined?(super)
project.feature_available?(:multiple_issue_boards) || super
parent.feature_available?(:multiple_issue_boards) || super
end
end
end
......
module EE
module Boards
module Issues
module ListService
def set_parent
if @parent.is_a?(Group)
params[:group_id] = @parent.id
else
super
end
end
end
end
end
end
......@@ -4,7 +4,7 @@ module EE
def execute
raise NotImplementedError unless defined?(super)
if project.feature_available?(:multiple_issue_boards, current_user)
if parent.multiple_issue_boards_available?(current_user)
super
else
super.limit(1)
......
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