Merge branch 'ee_issue_928_backport' into 'master'

Group boards CE backport

See merge request !13883
This commit is contained in:
Sean McGivern 2017-09-07 13:42:23 +00:00
commit b4778a5866
78 changed files with 776 additions and 529 deletions

View File

@ -6,7 +6,8 @@ const Api = {
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/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',
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',

View File

@ -53,7 +53,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,
@ -68,7 +69,13 @@ $(() => {
},
},
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.setup();
@ -112,19 +119,21 @@ $(() => {
gl.IssueBoardsSearch = new Vue({
el: document.getElementById('js-add-list'),
data: {
filters: Store.state.filters
filters: Store.state.filters,
},
mounted () {
gl.issueBoards.newListDropdownInit();
}
},
});
gl.IssueBoardsModalAddBtn = new Vue({
mixins: [gl.issueBoards.ModalMixins],
el: document.getElementById('js-add-issues-btn'),
data: {
modal: ModalStore.store,
store: Store.state,
data() {
return {
modal: ModalStore.store,
store: Store.state,
};
},
watch: {
disabled() {
@ -133,6 +142,9 @@ $(() => {
},
computed: {
disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
@ -145,7 +157,7 @@ $(() => {
},
methods: {
updateTooltip() {
const $tooltip = $(this.$el);
const $tooltip = $(this.$refs.addIssuesButton);
this.$nextTick(() => {
if (this.disabled) {
@ -165,16 +177,19 @@ $(() => {
this.updateTooltip();
},
template: `
<button
class="btn btn-create pull-right prepend-left-10"
type="button"
data-placement="bottom"
:class="{ 'disabled': disabled }"
:title="tooltipTitle"
:aria-disabled="disabled"
@click="openModal">
Add issues
</button>
<div class="board-extra-actions">
<button
class="btn btn-create prepend-left-10"
type="button"
data-placement="bottom"
ref="addIssuesButton"
:class="{ 'disabled': disabled }"
:title="tooltipTitle"
:aria-disabled="disabled"
@click="openModal">
Add issues
</button>
</div>
`,
});
});

View File

@ -77,7 +77,7 @@ export default {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) {
this.loadNextPage();
}
},
@ -165,11 +165,9 @@ export default {
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
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
class="board-list"
v-show="!loading"

View File

@ -6,7 +6,10 @@ const Store = gl.issueBoards.BoardsStore;
export default {
name: 'BoardNewIssue',
props: {
list: Object,
list: {
type: Object,
required: true,
},
},
data() {
return {

View File

@ -64,10 +64,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
return `${this.issueLinkBase}/${this.issue.id}`;
return `${this.issueLinkBase}/${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;
@ -143,7 +146,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>

View File

@ -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, {

View File

@ -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);
});

View File

@ -18,17 +18,33 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate;
},
},
methods: {
removeIssue() {
const issue = this.issue;
const lists = issue.getLists();
const labelIds = lists.map(list => list.label.id);
// Post the remove data
gl.boardService.bulkUpdate([issue.globalId], {
remove_label_ids: labelIds,
}).catch(() => {
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 = {
issue: {
label_ids: labelIds,
},
};
Vue.http.patch(this.updateUrl, data).catch(() => {
new Flash('Failed to remove issue from board, please try again.', 'alert');
lists.forEach((list) => {

View File

@ -7,8 +7,8 @@ import Vue from 'vue';
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;

View File

@ -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;

View File

@ -110,11 +110,13 @@ 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.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);
}
});
}
@ -126,19 +128,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);
@ -151,30 +153,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) {

View File

@ -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,
@ -60,12 +60,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,
});
}

View File

@ -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;

View File

@ -117,13 +117,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);
}
}
@ -151,11 +150,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;
@ -176,12 +182,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 {
@ -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 {
height: calc(100% - 49px);
overflow: hidden;
position: relative;
}
.board-list {
@ -429,7 +406,7 @@
}
.board-new-issue-form {
z-index: 1;
z-index: 4;
margin: 5px;
}

View File

@ -0,0 +1,21 @@
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

View File

@ -0,0 +1,90 @@
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

View File

@ -0,0 +1,75 @@
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

View File

@ -0,0 +1,42 @@
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

View File

@ -1,15 +0,0 @@
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

View File

@ -1,94 +0,0 @@
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

View File

@ -1,86 +0,0 @@
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

View File

@ -1,32 +1,31 @@
class Projects::BoardsController < Projects::ApplicationController
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
@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
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

View File

@ -1,15 +1,80 @@
module 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,
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
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

View File

@ -347,6 +347,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",

View File

@ -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

View File

@ -134,19 +134,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

View File

@ -3,7 +3,19 @@ class Board < ActiveRecord::Base
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
lists.merge(List.backlog).take

View File

@ -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]

View File

@ -34,7 +34,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 +173,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

View File

@ -1486,6 +1486,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

View File

@ -0,0 +1,10 @@
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

View File

@ -1,5 +1,5 @@
module Boards
class CreateService < BaseService
class CreateService < Boards::BaseService
def execute
create_board! if can_create_board?
end
@ -7,11 +7,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)

View File

@ -1,6 +1,14 @@
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

View File

@ -1,6 +1,6 @@
module Boards
module Issues
class ListService < BaseService
class ListService < Boards::BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
@ -11,7 +11,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 +33,14 @@ 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

View File

@ -1,17 +1,17 @@
module Boards
module Issues
class MoveService < BaseService
class MoveService < Boards::BaseService
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 +27,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 +42,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 +61,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

View File

@ -1,14 +1,14 @@
module Boards
class ListService < BaseService
class ListService < Boards::BaseService
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

View File

@ -1,19 +1,18 @@
module Boards
module Lists
class CreateService < BaseService
class CreateService < Boards::BaseService
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)

View File

@ -1,6 +1,6 @@
module Boards
module Lists
class DestroyService < BaseService
class DestroyService < Boards::BaseService
def execute(list)
return false unless list.destroyable?

View File

@ -1,6 +1,6 @@
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

View File

@ -1,6 +1,6 @@
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?

View File

@ -1,6 +1,6 @@
module Boards
module Lists
class MoveService < BaseService
class MoveService < Boards::BaseService
def execute(list)
@board = list.board
@old_position = list.position

View File

@ -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(issue.project, before_id) if before_id
issue_after = get_issue_if_allowed(issue.project, 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(project, id)
issue = project.issues.find(id)
issue if can?(current_user, :update_issue, issue)
end

View File

@ -1 +1 @@
= render "show"
= render "shared/boards/show", board: @boards.first

View File

@ -1 +1 @@
= render "show"
= render "shared/boards/show", board: @board

View File

@ -9,7 +9,7 @@
= 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
= render "projects/issues/head"
@ -30,7 +30,7 @@
":root-path" => "rootPath",
":board-id" => "boardId",
":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'),
"new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,

View File

@ -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"' }
- 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 }' }
{{ 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,7 @@
"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" => "list",
":issues" => "list.issues",
@ -42,5 +43,5 @@
":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath",
"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"' }

View File

@ -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" }

View File

@ -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

View File

@ -1,7 +1,7 @@
.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_admin_issue?
%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

View File

@ -1,7 +1,7 @@
.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"

View File

@ -1,7 +1,7 @@
.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

View File

@ -1,5 +1,5 @@
- 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" }

View File

@ -0,0 +1 @@
= render "show"

View File

@ -0,0 +1 @@
= render "show"

View File

@ -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

View File

@ -104,13 +104,13 @@
= 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

View File

@ -74,6 +74,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

View File

@ -343,19 +343,7 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
resources :boards, only: [:index, :show] 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]

View File

@ -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.
- [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.
- [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles.
- [Merge Requests](user/project/merge_requests/index.md)

View File

@ -26,6 +26,7 @@ module Gitlab
apple-touch-icon.png
assets
autocomplete
boards
ci
dashboard
deploy.html

View File

@ -1,6 +1,6 @@
require 'spec_helper'
describe Projects::Boards::IssuesController do
describe Boards::IssuesController do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
@ -133,6 +133,22 @@ describe Projects::Boards::IssuesController do
expect(response).to have_http_status(404)
end
end
context 'with invalid board id' do
it 'returns a not found 404 response' do
create_issue user: user, board: 999, list: list1, title: 'New issue'
expect(response).to have_http_status(404)
end
end
context 'with invalid list id' do
it 'returns a not found 404 response' do
create_issue user: user, board: board, list: 999, title: 'New issue'
expect(response).to have_http_status(404)
end
end
end
context 'with unauthorized user' do
@ -146,17 +162,15 @@ describe Projects::Boards::IssuesController do
def create_issue(user:, board:, list:, title:)
sign_in(user)
post :create, namespace_id: project.namespace.to_param,
project_id: project,
board_id: board.to_param,
post :create, board_id: board.to_param,
list_id: list.to_param,
issue: { title: title },
issue: { title: title, project_id: project.id },
format: :json
end
end
describe 'PATCH update' do
let(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
let!(:issue) { create(:labeled_issue, project: project, labels: [planning]) }
context 'with valid params' do
it 'returns a successful 200 response' do
@ -186,7 +200,7 @@ describe Projects::Boards::IssuesController do
end
it 'returns a not found 404 response for invalid issue id' do
move user: user, board: board, issue: 999, from_list_id: list1.id, to_list_id: list2.id
move user: user, board: board, issue: double(id: 999), from_list_id: list1.id, to_list_id: list2.id
expect(response).to have_http_status(404)
end
@ -210,9 +224,9 @@ describe Projects::Boards::IssuesController do
sign_in(user)
patch :update, namespace_id: project.namespace.to_param,
project_id: project,
project_id: project.id,
board_id: board.to_param,
id: issue.to_param,
id: issue.id,
from_list_id: from_list_id,
to_list_id: to_list_id,
format: :json

View File

@ -1,6 +1,6 @@
require 'spec_helper'
describe Projects::Boards::ListsController do
describe Boards::ListsController do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }

View File

@ -7,6 +7,7 @@ FactoryGirl.define do
group nil
project_id nil
group_id nil
parent nil
end
trait :active do
@ -26,6 +27,9 @@ FactoryGirl.define do
milestone.project = evaluator.project
elsif evaluator.project_id
milestone.project_id = evaluator.project_id
elsif evaluator.parent
id = evaluator.parent.id
evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
else
milestone.project = create(:project)
end

View File

@ -8,10 +8,15 @@
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"confidential": { "type": "boolean" },
"due_date": { "type": ["date", "null"] },
"relative_position": { "type": "integer" },
"project": {
"id": { "type": "integer" },
"path": { "type": "string" }
},
"labels": {
"type": "array",
"items": {
@ -34,6 +39,7 @@
"type": "string",
"pattern": "^#[0-9A-Fa-f]{3}{1,2}+$"
},
"type": { "type": "string" },
"title": { "type": "string" },
"priority": { "type": ["integer", "null"] }
},

View File

@ -1,4 +1,5 @@
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/stores/boards_store';
import boardBlankState from '~/boards/components/board_blank_state';
@ -12,7 +13,7 @@ describe('Boards blank state', () => {
const Comp = Vue.extend(boardBlankState);
gl.issueBoards.BoardsStore.create();
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => {
if (fail) {

View File

@ -4,6 +4,7 @@
/* global listObj */
/* global boardsMockInterceptor */
/* global BoardService */
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/models/assignee';
@ -14,13 +15,13 @@ import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card';
import './mock_data';
describe('Issue card', () => {
describe('Board card', () => {
let vm;
beforeEach((done) => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.issueBoards.BoardsStore.detail.issue = {};

View File

@ -3,6 +3,7 @@
/* global List */
/* global listObj */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import _ from 'underscore';
import Sortable from 'vendor/Sortable';
@ -24,7 +25,7 @@ describe('Board list component', () => {
document.body.appendChild(el);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();
@ -32,6 +33,7 @@ describe('Board list component', () => {
const list = new List(listObj);
const issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],

View File

@ -2,6 +2,7 @@
/* global BoardService */
/* global List */
/* global listObj */
/* global mockBoardService */
import Vue from 'vue';
import boardNewIssue from '~/boards/components/board_new_issue';
@ -35,7 +36,7 @@ describe('Issue boards new issue form', () => {
const BoardNewIssueComp = Vue.extend(boardNewIssue);
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
gl.IssueBoardsApp = new Vue();

View File

@ -4,6 +4,7 @@
/* global listObj */
/* global listObjDuplicate */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import Cookies from 'js-cookie';
@ -20,7 +21,7 @@ import './mock_data';
describe('Store', () => {
beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => {
@ -78,7 +79,7 @@ describe('Store', () => {
it('persists new list', (done) => {
gl.issueBoards.BoardsStore.new({
title: 'Test',
type: 'label',
list_type: 'label',
label: {
id: 1,
title: 'Testing',
@ -210,6 +211,7 @@ describe('Store', () => {
it('moves issue in list', (done) => {
const issue = new ListIssue({
title: 'Testing',
id: 2,
iid: 2,
confidential: false,
labels: [],

View File

@ -1,7 +1,9 @@
/* global mockBoardService */
import Vue from 'vue';
import '~/boards/services/board_service';
import '~/boards/components/board';
import '~/boards/models/list';
import '../mock_data';
describe('Board component', () => {
let vm;
@ -13,8 +15,12 @@ describe('Board component', () => {
el = document.createElement('div');
document.body.appendChild(el);
// eslint-disable-next-line no-undef
gl.boardService = new BoardService('/', '/', 1);
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
vm = new gl.issueBoards.Board({
propsData: {

View File

@ -37,6 +37,7 @@ describe('Issue card component', () => {
list = listObj;
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [list.label],
@ -238,65 +239,63 @@ describe('Issue card component', () => {
});
describe('labels', () => {
describe('exists', () => {
beforeEach((done) => {
component.issue.addLabel(label1);
beforeEach((done) => {
component.issue.addLabel(label1);
Vue.nextTick(() => done());
Vue.nextTick(() => done());
});
it('renders list label', () => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.title);
});
it('renders list label', () => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
nodes.includes(label1.description),
).toBe(true);
});
it('sets label description as title', () => {
expect(
component.$el.querySelector('.label').getAttribute('title'),
).toContain(label1.description);
});
it('sets background color of button', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.style.backgroundColor);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.title);
});
expect(
nodes.includes(label1.color),
).toBe(true);
});
expect(
nodes.includes(label1.description),
).toBe(true);
});
it('does not render label if label does not have an ID', (done) => {
component.issue.addLabel(new ListLabel({
title: 'closed',
}));
it('sets label description as title', () => {
expect(
component.$el.querySelector('.label').getAttribute('title'),
).toContain(label1.description);
});
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
it('sets background color of button', () => {
const nodes = [];
component.$el.querySelectorAll('.label').forEach((label) => {
nodes.push(label.style.backgroundColor);
});
expect(
nodes.includes(label1.color),
).toBe(true);
});
it('does not render label if label does not have an ID', (done) => {
component.issue.addLabel(new ListLabel({
title: 'closed',
}));
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.label').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
done();
})
.catch(done.fail);
});
done();
})
.catch(done.fail);
});
});
});

View File

@ -1,6 +1,7 @@
/* eslint-disable comma-dangle */
/* global BoardService */
/* global ListIssue */
/* global mockBoardService */
import Vue from 'vue';
import '~/lib/utils/url_utility';
@ -16,11 +17,12 @@ describe('Issue model', () => {
let issue;
beforeEach(() => {
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService();
gl.issueBoards.BoardsStore.create();
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [{

View File

@ -1,6 +1,7 @@
/* eslint-disable comma-dangle */
/* global boardsMockInterceptor */
/* global BoardService */
/* global mockBoardService */
/* global List */
/* global ListIssue */
/* global listObj */
@ -22,7 +23,9 @@ describe('List model', () => {
beforeEach(() => {
Vue.http.interceptors.push(boardsMockInterceptor);
gl.boardService = new BoardService('/test/issue-boards/board', '', '1');
gl.boardService = mockBoardService({
bulkUpdatePath: '/test/issue-boards/board/1/lists',
});
gl.issueBoards.BoardsStore.create();
list = new List(listObj);
@ -92,6 +95,7 @@ describe('List model', () => {
const listDup = new List(listObjDuplicate);
const issue = new ListIssue({
title: 'Testing',
id: _.random(10000),
iid: _.random(10000),
confidential: false,
labels: [list.label, listDup.label],
@ -118,6 +122,7 @@ describe('List model', () => {
for (let i = 0; i < 30; i += 1) {
list.issues.push(new ListIssue({
title: 'Testing',
id: _.random(10000) + i,
iid: _.random(10000) + i,
confidential: false,
labels: [list.label],
@ -137,7 +142,7 @@ describe('List model', () => {
it('does not increase page number if issue count is less than the page size', () => {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
@ -156,7 +161,7 @@ describe('List model', () => {
spyOn(gl.boardService, 'newIssue').and.returnValue(Promise.resolve({
json() {
return {
iid: 42,
id: 42,
};
},
}));
@ -165,14 +170,14 @@ describe('List model', () => {
it('adds new issue to top of list', (done) => {
list.issues.push(new ListIssue({
title: 'Testing',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],
}));
const dummyIssue = new ListIssue({
title: 'new issue',
iid: _.random(10000),
id: _.random(10000),
confidential: false,
labels: [list.label],
assignees: [],

View File

@ -1,3 +1,4 @@
/* global BoardService */
/* eslint-disable comma-dangle, no-unused-vars, quote-props */
const listObj = {
@ -28,19 +29,19 @@ const listObjDuplicate = {
const BoardsMockData = {
'GET': {
'/test/issue-boards/board/1/lists{/id}/issues': {
'/test/boards/1{/id}/issues': {
issues: [{
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
}],
size: 1
}
},
'POST': {
'/test/issue-boards/board/1/lists{/id}': listObj
'/test/boards/1{/id}': listObj
},
'PUT': {
'/test/issue-boards/board/1/lists{/id}': {}
@ -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.listObjDuplicate = listObjDuplicate;
window.BoardsMockData = BoardsMockData;
window.boardsMockInterceptor = boardsMockInterceptor;
window.mockBoardService = mockBoardService;

View File

@ -18,6 +18,7 @@ describe('Modal store', () => {
issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
@ -25,6 +26,7 @@ describe('Modal store', () => {
});
issue2 = new ListIssue({
title: 'Testing',
id: 1,
iid: 2,
confidential: false,
labels: [],

View File

@ -8,7 +8,7 @@ describe Boards::Issues::CreateService do
let(:label) { create(:label, project: project, name: 'in-progress') }
let!(:list) { create(:list, board: board, label: label, position: 0) }
subject(:service) { described_class.new(project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
before do
project.team << [user, :developer]

View File

@ -98,7 +98,7 @@ describe Boards::Issues::MoveService do
issue.move_to_end && issue.save!
end
params.merge!(move_after_iid: issue1.iid, move_before_iid: issue2.iid)
params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
described_class.new(project, user, params).execute(issue)

View File

@ -80,7 +80,7 @@ describe Issues::UpdateService, :mailer do
issue.save
end
opts[:move_between_iids] = [issue1.iid, issue2.iid]
opts[:move_between_ids] = [issue1.id, issue2.id]
update_issue(opts)