Commit b4778a58 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ee_issue_928_backport' into 'master'

Group boards CE backport

See merge request !13883
parents cb555da1 a1a839c9
...@@ -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', projectsPath: '/api/:version/projects.json',
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',
......
...@@ -53,7 +53,8 @@ $(() => { ...@@ -53,7 +53,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,
...@@ -68,7 +69,13 @@ $(() => { ...@@ -68,7 +69,13 @@ $(() => {
}, },
}, },
created () { created () {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); 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.filterManager = new FilteredSearchBoards(Store.filter, true);
this.filterManager.setup(); this.filterManager.setup();
...@@ -112,19 +119,21 @@ $(() => { ...@@ -112,19 +119,21 @@ $(() => {
gl.IssueBoardsSearch = new Vue({ gl.IssueBoardsSearch = new Vue({
el: document.getElementById('js-add-list'), el: document.getElementById('js-add-list'),
data: { data: {
filters: Store.state.filters filters: Store.state.filters,
}, },
mounted () { mounted () {
gl.issueBoards.newListDropdownInit(); gl.issueBoards.newListDropdownInit();
} },
}); });
gl.IssueBoardsModalAddBtn = new Vue({ gl.IssueBoardsModalAddBtn = new Vue({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
el: document.getElementById('js-add-issues-btn'), el: document.getElementById('js-add-issues-btn'),
data: { data() {
modal: ModalStore.store, return {
store: Store.state, modal: ModalStore.store,
store: Store.state,
};
}, },
watch: { watch: {
disabled() { disabled() {
...@@ -133,6 +142,9 @@ $(() => { ...@@ -133,6 +142,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() {
...@@ -145,7 +157,7 @@ $(() => { ...@@ -145,7 +157,7 @@ $(() => {
}, },
methods: { methods: {
updateTooltip() { updateTooltip() {
const $tooltip = $(this.$el); const $tooltip = $(this.$refs.addIssuesButton);
this.$nextTick(() => { this.$nextTick(() => {
if (this.disabled) { if (this.disabled) {
...@@ -165,16 +177,19 @@ $(() => { ...@@ -165,16 +177,19 @@ $(() => {
this.updateTooltip(); this.updateTooltip();
}, },
template: ` template: `
<button <div class="board-extra-actions">
class="btn btn-create pull-right prepend-left-10" <button
type="button" class="btn btn-create prepend-left-10"
data-placement="bottom" type="button"
:class="{ 'disabled': disabled }" data-placement="bottom"
:title="tooltipTitle" ref="addIssuesButton"
:aria-disabled="disabled" :class="{ 'disabled': disabled }"
@click="openModal"> :title="tooltipTitle"
Add issues :aria-disabled="disabled"
</button> @click="openModal">
Add issues
</button>
</div>
`, `,
}); });
}); });
...@@ -77,7 +77,7 @@ export default { ...@@ -77,7 +77,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.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
this.loadNextPage(); this.loadNextPage();
} }
}, },
...@@ -165,11 +165,9 @@ export default { ...@@ -165,11 +165,9 @@ export default {
v-if="loading"> v-if="loading">
<loading-icon /> <loading-icon />
</div> </div>
<transition name="slide-down"> <board-new-issue
<board-new-issue :list="list"
:list="list" v-if="list.type !== 'closed' && showIssueForm"/>
v-if="list.type !== 'closed' && showIssueForm"/>
</transition>
<ul <ul
class="board-list" class="board-list"
v-show="!loading" v-show="!loading"
......
...@@ -6,7 +6,10 @@ const Store = gl.issueBoards.BoardsStore; ...@@ -6,7 +6,10 @@ const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
props: { props: {
list: Object, list: {
type: Object,
required: true,
},
}, },
data() { data() {
return { return {
......
...@@ -64,10 +64,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -64,10 +64,13 @@ 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}`; return `${this.issueLinkBase}/${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;
...@@ -143,7 +146,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -143,7 +146,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);
}); });
......
...@@ -18,17 +18,33 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ ...@@ -18,17 +18,33 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object, type: Object,
required: true, required: true,
}, },
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate;
},
}, },
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
// Post the remove data .map(label => label.id)
gl.boardService.bulkUpdate([issue.globalId], { .filter(id => !listLabelIds.includes(id));
remove_label_ids: labelIds, if (labelIds.length === 0) {
}).catch(() => { labelIds = [''];
}
const data = {
issue: {
label_ids: labelIds,
},
};
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) => {
......
...@@ -7,8 +7,8 @@ import Vue from 'vue'; ...@@ -7,8 +7,8 @@ import Vue from 'vue';
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;
......
...@@ -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,11 +110,13 @@ class List { ...@@ -110,11 +110,13 @@ 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.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);
} }
}); });
} }
...@@ -126,19 +128,19 @@ class List { ...@@ -126,19 +128,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);
...@@ -151,30 +153,30 @@ class List { ...@@ -151,30 +153,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) {
......
...@@ -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,
...@@ -60,12 +60,12 @@ class BoardService { ...@@ -60,12 +60,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,
}); });
} }
......
...@@ -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;
......
...@@ -117,13 +117,12 @@ ...@@ -117,13 +117,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);
} }
} }
...@@ -151,11 +150,18 @@ ...@@ -151,11 +150,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;
...@@ -176,12 +182,16 @@ ...@@ -176,12 +182,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 {
...@@ -221,43 +231,10 @@ ...@@ -221,43 +231,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 {
...@@ -429,7 +406,7 @@ ...@@ -429,7 +406,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
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)
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
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
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)
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).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
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
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,
disabled: "#{!can?(current_user, :admin_list, @project)}", disabled: "#{!can?(current_user, :admin_list, current_board_parent)}",
issue_link_base: project_issues_path(@project), issue_link_base: build_issue_link_base,
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
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id],
include: {
milestone: { only: [:title] }
}
)
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
...@@ -347,6 +347,14 @@ module IssuablesHelper ...@@ -347,6 +347,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
......
...@@ -134,19 +134,21 @@ module SearchHelper ...@@ -134,19 +134,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
......
...@@ -3,7 +3,19 @@ class Board < ActiveRecord::Base ...@@ -3,7 +3,19 @@ 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 :project, presence: true validates :project, presence: true, if: :project_needed?
def project_needed?
true
end
def parent
project
end
def group_board?
false
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]
......
...@@ -34,7 +34,8 @@ class Label < ActiveRecord::Base ...@@ -34,7 +34,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 +173,7 @@ class Label < ActiveRecord::Base ...@@ -172,6 +173,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
......
...@@ -1486,6 +1486,14 @@ class Project < ActiveRecord::Base ...@@ -1486,6 +1486,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
......
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
def execute def execute
create_board! if can_create_board? create_board! if can_create_board?
end end
...@@ -7,11 +7,11 @@ module Boards ...@@ -7,11 +7,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
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
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 +11,7 @@ module Boards ...@@ -11,7 +11,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 +33,14 @@ module Boards ...@@ -33,14 +33,14 @@ 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
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 +27,8 @@ module Boards ...@@ -27,8 +27,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 +42,7 @@ module Boards ...@@ -42,7 +42,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 +61,16 @@ module Boards ...@@ -61,16 +61,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
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
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
......
...@@ -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(issue.project, before_id) if before_id
issue_after = get_issue_if_allowed(issue.project, after_iid) if after_iid issue_after = get_issue_if_allowed(issue.project, 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(project, id)
issue = project.issues.find_by(iid: iid) issue = project.issues.find(id)
issue if can?(current_user, :update_issue, issue) issue if can?(current_user, :update_issue, issue)
end end
......
= render "show" = render "shared/boards/show", board: @boards.first
= render "show" = render "shared/boards/show", board: @board
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
= 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
= render "projects/issues/head" = render "projects/issues/head"
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
":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'), %board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'),
"new-issue-path" => new_project_issue_path(@project), "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path, "milestone-path" => milestones_filter_dropdown_path,
......
...@@ -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"' } - 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.pull-right{ 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"' }
%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,7 @@ ...@@ -28,12 +34,7 @@
"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"', %board-list{ "v-if" => 'list.type !== "blank"',
":list" => "list", ":list" => "list",
":issues" => "list.issues", ":issues" => "list.issues",
...@@ -42,5 +43,5 @@ ...@@ -42,5 +43,5 @@
":issue-link-base" => "issueLinkBase", ":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath", ":root-path" => "rootPath",
"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"' }
...@@ -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_admin_issue?
%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
......
...@@ -104,13 +104,13 @@ ...@@ -104,13 +104,13 @@
= 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 #js-add-issues-btn.prepend-left-10
......
...@@ -74,6 +74,19 @@ Rails.application.routes.draw do ...@@ -74,6 +74,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
......
...@@ -343,19 +343,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -343,19 +343,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] 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]
......
...@@ -84,7 +84,7 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ...@@ -84,7 +84,7 @@ 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)
- [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.
- [Merge Requests](user/project/merge_requests/index.md) - [Merge Requests](user/project/merge_requests/index.md)
......
...@@ -26,6 +26,7 @@ module Gitlab ...@@ -26,6 +26,7 @@ module Gitlab
apple-touch-icon.png apple-touch-icon.png
assets assets
autocomplete autocomplete
boards
ci ci
dashboard dashboard
deploy.html deploy.html
......
require 'spec_helper' require 'spec_helper'
describe Projects::Boards::IssuesController do describe Boards::IssuesController do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -133,6 +133,22 @@ describe Projects::Boards::IssuesController do ...@@ -133,6 +133,22 @@ describe Projects::Boards::IssuesController do
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
end end
context 'with invalid board id' do
it 'returns a not found 404 response' do
create_issue user: user, board: 999, list: list1, title: 'New issue'
expect(response).to have_http_status(404)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
create_issue user: user, board: board, list: 999, title: 'New issue'
expect(response).to have_http_status(404)
end
end
end end
context 'with unauthorized user' do context 'with unauthorized user' do
...@@ -146,17 +162,15 @@ describe Projects::Boards::IssuesController do ...@@ -146,17 +162,15 @@ describe Projects::Boards::IssuesController do
def create_issue(user:, board:, list:, title:) def create_issue(user:, board:, list:, title:)
sign_in(user) sign_in(user)
post :create, namespace_id: project.namespace.to_param, post :create, board_id: board.to_param,
project_id: project,
board_id: board.to_param,
list_id: list.to_param, list_id: list.to_param,
issue: { title: title }, issue: { title: title, project_id: project.id },
format: :json format: :json
end end
end end
describe 'PATCH update' do describe 'PATCH update' do
let(:issue) { create(:labeled_issue, project: project, labels: [planning]) } let!(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
context 'with valid params' do context 'with valid params' do
it 'returns a successful 200 response' do it 'returns a successful 200 response' do
...@@ -186,7 +200,7 @@ describe Projects::Boards::IssuesController do ...@@ -186,7 +200,7 @@ describe Projects::Boards::IssuesController do
end end
it 'returns a not found 404 response for invalid issue id' do it 'returns a not found 404 response for invalid issue id' do
move user: user, board: board, issue: 999, from_list_id: list1.id, to_list_id: list2.id move user: user, board: board, issue: double(id: 999), from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
...@@ -210,9 +224,9 @@ describe Projects::Boards::IssuesController do ...@@ -210,9 +224,9 @@ describe Projects::Boards::IssuesController do
sign_in(user) sign_in(user)
patch :update, namespace_id: project.namespace.to_param, patch :update, namespace_id: project.namespace.to_param,
project_id: project, project_id: project.id,
board_id: board.to_param, board_id: board.to_param,
id: issue.to_param, id: issue.id,
from_list_id: from_list_id, from_list_id: from_list_id,
to_list_id: to_list_id, to_list_id: to_list_id,
format: :json format: :json
......
require 'spec_helper' require 'spec_helper'
describe Projects::Boards::ListsController do describe Boards::ListsController do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
......
...@@ -7,6 +7,7 @@ FactoryGirl.define do ...@@ -7,6 +7,7 @@ FactoryGirl.define do
group nil group nil
project_id nil project_id nil
group_id nil group_id nil
parent nil
end end
trait :active do trait :active do
...@@ -26,6 +27,9 @@ FactoryGirl.define do ...@@ -26,6 +27,9 @@ FactoryGirl.define do
milestone.project = evaluator.project milestone.project = evaluator.project
elsif evaluator.project_id elsif evaluator.project_id
milestone.project_id = evaluator.project_id milestone.project_id = evaluator.project_id
elsif evaluator.parent
id = evaluator.parent.id
evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
else else
milestone.project = create(:project) milestone.project = create(:project)
end end
......
...@@ -8,10 +8,15 @@ ...@@ -8,10 +8,15 @@
"properties" : { "properties" : {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"confidential": { "type": "boolean" }, "confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] }, "due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" }, "relative_position": { "type": "integer" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
},
"labels": { "labels": {
"type": "array", "type": "array",
"items": { "items": {
...@@ -34,6 +39,7 @@ ...@@ -34,6 +39,7 @@
"type": "string", "type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
}, },
"type": { "type": "string" },
"title": { "type": "string" }, "title": { "type": "string" },
"priority": { "type": ["integer", "null"] } "priority": { "type": ["integer", "null"] }
}, },
......
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import boardBlankState from '~/boards/components/board_blank_state'; import boardBlankState from '~/boards/components/board_blank_state';
...@@ -12,7 +13,7 @@ describe('Boards blank state', () => { ...@@ -12,7 +13,7 @@ describe('Boards blank state', () => {
const Comp = Vue.extend(boardBlankState); const Comp = Vue.extend(boardBlankState);
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => { spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
if (fail) { if (fail) {
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
/* global listObj */ /* global listObj */
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/models/assignee'; import '~/boards/models/assignee';
...@@ -14,13 +15,13 @@ import '~/boards/stores/boards_store'; ...@@ -14,13 +15,13 @@ import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card'; import boardCard from '~/boards/components/board_card';
import './mock_data'; import './mock_data';
describe('Issue card', () => { describe('Board card', () => {
let vm; let vm;
beforeEach((done) => { beforeEach((done) => {
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.issueBoards.BoardsStore.detail.issue = {}; gl.issueBoards.BoardsStore.detail.issue = {};
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
/* global List */ /* global List */
/* global listObj */ /* global listObj */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import Sortable from 'vendor/Sortable'; import Sortable from 'vendor/Sortable';
...@@ -24,7 +25,7 @@ describe('Board list component', () => { ...@@ -24,7 +25,7 @@ describe('Board list component', () => {
document.body.appendChild(el); document.body.appendChild(el);
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue(); gl.IssueBoardsApp = new Vue();
...@@ -32,6 +33,7 @@ describe('Board list component', () => { ...@@ -32,6 +33,7 @@ describe('Board list component', () => {
const list = new List(listObj); const list = new List(listObj);
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
/* global BoardService */ /* global BoardService */
/* global List */ /* global List */
/* global listObj */ /* global listObj */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import boardNewIssue from '~/boards/components/board_new_issue'; import boardNewIssue from '~/boards/components/board_new_issue';
...@@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => { ...@@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => {
const BoardNewIssueComp = Vue.extend(boardNewIssue); const BoardNewIssueComp = Vue.extend(boardNewIssue);
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue(); gl.IssueBoardsApp = new Vue();
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
/* global listObj */ /* global listObj */
/* global listObjDuplicate */ /* global listObjDuplicate */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
...@@ -20,7 +21,7 @@ import './mock_data'; ...@@ -20,7 +21,7 @@ import './mock_data';
describe('Store', () => { describe('Store', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => { spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => {
...@@ -78,7 +79,7 @@ describe('Store', () => { ...@@ -78,7 +79,7 @@ describe('Store', () => {
it('persists new list', (done) => { it('persists new list', (done) => {
gl.issueBoards.BoardsStore.new({ gl.issueBoards.BoardsStore.new({
title: 'Test', title: 'Test',
type: 'label', list_type: 'label',
label: { label: {
id: 1, id: 1,
title: 'Testing', title: 'Testing',
...@@ -210,6 +211,7 @@ describe('Store', () => { ...@@ -210,6 +211,7 @@ describe('Store', () => {
it('moves issue in list', (done) => { it('moves issue in list', (done) => {
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 2,
iid: 2, iid: 2,
confidential: false, confidential: false,
labels: [], labels: [],
......
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import '~/boards/components/board'; import '~/boards/components/board';
import '~/boards/models/list'; import '~/boards/models/list';
import '../mock_data';
describe('Board component', () => { describe('Board component', () => {
let vm; let vm;
...@@ -13,8 +15,12 @@ describe('Board component', () => { ...@@ -13,8 +15,12 @@ describe('Board component', () => {
el = document.createElement('div'); el = document.createElement('div');
document.body.appendChild(el); document.body.appendChild(el);
// eslint-disable-next-line no-undef gl.boardService = mockBoardService({
gl.boardService = new BoardService('/', '/', 1); boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
vm = new gl.issueBoards.Board({ vm = new gl.issueBoards.Board({
propsData: { propsData: {
......
...@@ -37,6 +37,7 @@ describe('Issue card component', () => { ...@@ -37,6 +37,7 @@ describe('Issue card component', () => {
list = listObj; list = listObj;
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
...@@ -238,65 +239,63 @@ describe('Issue card component', () => { ...@@ -238,65 +239,63 @@ describe('Issue card component', () => {
}); });
describe('labels', () => { describe('labels', () => {
describe('exists', () => { beforeEach((done) => {
beforeEach((done) => { component.issue.addLabel(label1);
component.issue.addLabel(label1);
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
it('renders list label', () => { it('renders list label', () => {
expect( expect(
component.$el.querySelectorAll('.label').length, component.$el.querySelectorAll('.label').length,
).toBe(2); ).toBe(2);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.title);
}); });
it('renders label', () => { expect(
const nodes = []; nodes.includes(label1.description),
component.$el.querySelectorAll('.label').forEach((label) => { ).toBe(true);
nodes.push(label.title); });
});
expect( it('sets label description as title', () => {
nodes.includes(label1.description), expect(
).toBe(true); component.$el.querySelector('.label').getAttribute('title'),
}); ).toContain(label1.description);
});
it('sets label description as title', () => { it('sets background color of button', () => {
expect( const nodes = [];
component.$el.querySelector('.label').getAttribute('title'), component.$el.querySelectorAll('.label').forEach((label) => {
).toContain(label1.description); nodes.push(label.style.backgroundColor);
}); });
it('sets background color of button', () => { expect(
const nodes = []; nodes.includes(label1.color),
component.$el.querySelectorAll('.label').forEach((label) => { ).toBe(true);
nodes.push(label.style.backgroundColor); });
});
expect( it('does not render label if label does not have an ID', (done) => {
nodes.includes(label1.color), component.issue.addLabel(new ListLabel({
).toBe(true); title: 'closed',
}); }));
it('does not render label if label does not have an ID', (done) => { Vue.nextTick()
component.issue.addLabel(new ListLabel({ .then(() => {
title: 'closed', expect(
})); component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
Vue.nextTick() done();
.then(() => { })
expect( .catch(done.fail);
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
done();
})
.catch(done.fail);
});
}); });
}); });
}); });
/* eslint-disable comma-dangle */ /* eslint-disable comma-dangle */
/* global BoardService */ /* global BoardService */
/* global ListIssue */ /* global ListIssue */
/* global mockBoardService */
import Vue from 'vue'; import Vue from 'vue';
import '~/lib/utils/url_utility'; import '~/lib/utils/url_utility';
...@@ -16,11 +17,12 @@ describe('Issue model', () => { ...@@ -16,11 +17,12 @@ describe('Issue model', () => {
let issue; let issue;
beforeEach(() => { beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [{ labels: [{
......
/* eslint-disable comma-dangle */ /* eslint-disable comma-dangle */
/* global boardsMockInterceptor */ /* global boardsMockInterceptor */
/* global BoardService */ /* global BoardService */
/* global mockBoardService */
/* global List */ /* global List */
/* global ListIssue */ /* global ListIssue */
/* global listObj */ /* global listObj */
...@@ -22,7 +23,9 @@ describe('List model', () => { ...@@ -22,7 +23,9 @@ describe('List model', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor); Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.boardService = mockBoardService({
bulkUpdatePath: '/test/issue-boards/board/1/lists',
});
gl.issueBoards.BoardsStore.create(); gl.issueBoards.BoardsStore.create();
list = new List(listObj); list = new List(listObj);
...@@ -92,6 +95,7 @@ describe('List model', () => { ...@@ -92,6 +95,7 @@ describe('List model', () => {
const listDup = new List(listObjDuplicate); const listDup = new List(listObjDuplicate);
const issue = new ListIssue({ const issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: _.random(10000),
iid: _.random(10000), iid: _.random(10000),
confidential: false, confidential: false,
labels: [list.label, listDup.label], labels: [list.label, listDup.label],
...@@ -118,6 +122,7 @@ describe('List model', () => { ...@@ -118,6 +122,7 @@ describe('List model', () => {
for (let i = 0; i < 30; i += 1) { for (let i = 0; i < 30; i += 1) {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
id: _.random(10000) + i,
iid: _.random(10000) + i, iid: _.random(10000) + i,
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
...@@ -137,7 +142,7 @@ describe('List model', () => { ...@@ -137,7 +142,7 @@ describe('List model', () => {
it('does not increase page number if issue count is less than the page size', () => { it('does not increase page number if issue count is less than the page size', () => {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
...@@ -156,7 +161,7 @@ describe('List model', () => { ...@@ -156,7 +161,7 @@ describe('List model', () => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({ spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
json() { json() {
return { return {
iid: 42, id: 42,
}; };
}, },
})); }));
...@@ -165,14 +170,14 @@ describe('List model', () => { ...@@ -165,14 +170,14 @@ describe('List model', () => {
it('adds new issue to top of list', (done) => { it('adds new issue to top of list', (done) => {
list.issues.push(new ListIssue({ list.issues.push(new ListIssue({
title: 'Testing', title: 'Testing',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
})); }));
const dummyIssue = new ListIssue({ const dummyIssue = new ListIssue({
title: 'new issue', title: 'new issue',
iid: _.random(10000), id: _.random(10000),
confidential: false, confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
......
/* global BoardService */
/* eslint-disable comma-dangle, no-unused-vars, quote-props */ /* eslint-disable comma-dangle, no-unused-vars, quote-props */
const listObj = { const listObj = {
...@@ -28,19 +29,19 @@ const listObjDuplicate = { ...@@ -28,19 +29,19 @@ const listObjDuplicate = {
const BoardsMockData = { const BoardsMockData = {
'GET': { 'GET': {
'/test/issue-boards/board/1/lists{/id}/issues': { '/test/boards/1{/id}/issues': {
issues: [{ issues: [{
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
assignees: [], assignees: [],
}], }],
size: 1
} }
}, },
'POST': { 'POST': {
'/test/issue-boards/board/1/lists{/id}': listObj '/test/boards/1{/id}': listObj
}, },
'PUT': { 'PUT': {
'/test/issue-boards/board/1/lists{/id}': {} '/test/issue-boards/board/1/lists{/id}': {}
...@@ -58,7 +59,22 @@ const boardsMockInterceptor = (request, next) => { ...@@ -58,7 +59,22 @@ const boardsMockInterceptor = (request, next) => {
})); }));
}; };
const mockBoardService = (opts = {}) => {
const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/board';
const listsEndpoint = opts.listsEndpoint || '/test/boards/1';
const bulkUpdatePath = opts.bulkUpdatePath || '';
const boardId = opts.boardId || '1';
return new BoardService({
boardsEndpoint,
listsEndpoint,
bulkUpdatePath,
boardId,
});
};
window.listObj = listObj; window.listObj = listObj;
window.listObjDuplicate = listObjDuplicate; window.listObjDuplicate = listObjDuplicate;
window.BoardsMockData = BoardsMockData; window.BoardsMockData = BoardsMockData;
window.boardsMockInterceptor = boardsMockInterceptor; window.boardsMockInterceptor = boardsMockInterceptor;
window.mockBoardService = mockBoardService;
...@@ -18,6 +18,7 @@ describe('Modal store', () => { ...@@ -18,6 +18,7 @@ describe('Modal store', () => {
issue = new ListIssue({ issue = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 1, iid: 1,
confidential: false, confidential: false,
labels: [], labels: [],
...@@ -25,6 +26,7 @@ describe('Modal store', () => { ...@@ -25,6 +26,7 @@ describe('Modal store', () => {
}); });
issue2 = new ListIssue({ issue2 = new ListIssue({
title: 'Testing', title: 'Testing',
id: 1,
iid: 2, iid: 2,
confidential: false, confidential: false,
labels: [], labels: [],
......
...@@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do ...@@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do
let(:label) { create(:label, project: project, name: 'in-progress') } let(:label) { create(:label, project: project, name: 'in-progress') }
let!(:list) { create(:list, board: board, label: label, position: 0) } let!(:list) { create(:list, board: board, label: label, position: 0) }
subject(:service) { described_class.new(project, user, board_id: board.id, list_id: list.id, title: 'New issue') } subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
before do before do
project.team << [user, :developer] project.team << [user, :developer]
......
...@@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do ...@@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do
issue.move_to_end && issue.save! issue.move_to_end && issue.save!
end end
params.merge!(move_after_iid: issue1.iid, move_before_iid: issue2.iid) params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
described_class.new(project, user, params).execute(issue) described_class.new(project, user, params).execute(issue)
......
...@@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do ...@@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do
issue.save issue.save
end end
opts[:move_between_iids] = [issue1.iid, issue2.iid] opts[:move_between_ids] = [issue1.id, issue2.id]
update_issue(opts) update_issue(opts)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment