Commit d12f60a5 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'issue_38337' into 'master'

Bring one group board to CE

Closes #38337

See merge request gitlab-org/gitlab-ce!17274
parents cd611d67 e77c4e9e
<script> <script>
/* eslint-disable vue/require-default-prop */
import './issue_card_inner'; import './issue_card_inner';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
...@@ -34,6 +35,9 @@ export default { ...@@ -34,6 +35,9 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
groupId: {
type: Number,
},
}, },
data() { data() {
return { return {
...@@ -88,6 +92,7 @@ export default { ...@@ -88,6 +92,7 @@ export default {
:list="list" :list="list"
:issue="issue" :issue="issue"
:issue-link-base="issueLinkBase" :issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath" :root-path="rootPath"
:update-filters="true" :update-filters="true"
/> />
......
...@@ -15,6 +15,11 @@ export default { ...@@ -15,6 +15,11 @@ export default {
loadingIcon, loadingIcon,
}, },
props: { props: {
groupId: {
type: Number,
required: false,
default: 0,
},
disabled: { disabled: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -170,6 +175,7 @@ export default { ...@@ -170,6 +175,7 @@ export default {
<loading-icon /> <loading-icon />
</div> </div>
<board-new-issue <board-new-issue
:group-id="groupId"
:list="list" :list="list"
v-if="list.type !== 'closed' && showIssueForm"/> v-if="list.type !== 'closed' && showIssueForm"/>
<ul <ul
...@@ -185,6 +191,7 @@ export default { ...@@ -185,6 +191,7 @@ export default {
:list="list" :list="list"
:issue="issue" :issue="issue"
:issue-link-base="issueLinkBase" :issue-link-base="issueLinkBase"
:group-id="groupId"
:root-path="rootPath" :root-path="rootPath"
:disabled="disabled" :disabled="disabled"
:key="issue.id" /> :key="issue.id" />
......
<script> <script>
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
import ListIssue from '../models/issue'; import ListIssue from '../models/issue';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
components: {
ProjectSelect,
},
props: { props: {
groupId: {
type: Number,
required: false,
default: 0,
},
list: { list: {
type: Object, type: Object,
required: true, required: true,
...@@ -16,10 +25,20 @@ export default { ...@@ -16,10 +25,20 @@ export default {
return { return {
title: '', title: '',
error: false, error: false,
selectedProject: {},
}; };
}, },
computed: {
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
},
mounted() { mounted() {
this.$refs.input.focus(); this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
}, },
methods: { methods: {
submit(e) { submit(e) {
...@@ -34,6 +53,7 @@ export default { ...@@ -34,6 +53,7 @@ export default {
labels, labels,
subscribed: true, subscribed: true,
assignees: [], assignees: [],
project_id: this.selectedProject.id,
}); });
eventHub.$emit(`scroll-board-list-${this.list.id}`); eventHub.$emit(`scroll-board-list-${this.list.id}`);
...@@ -62,12 +82,16 @@ export default { ...@@ -62,12 +82,16 @@ export default {
this.title = ''; this.title = '';
eventHub.$emit(`hide-issue-form-${this.list.id}`); eventHub.$emit(`hide-issue-form-${this.list.id}`);
}, },
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
}, },
}; };
</script> </script>
<template> <template>
<div class="card board-new-issue-form"> <div class="board-new-issue-form">
<div class="card">
<form @submit="submit($event)"> <form @submit="submit($event)">
<div <div
class="flash-container" class="flash-container"
...@@ -91,11 +115,15 @@ export default { ...@@ -91,11 +115,15 @@ export default {
autocomplete="off" autocomplete="off"
:id="list.id + '-title'" :id="list.id + '-title'"
/> />
<project-select
v-if="groupId"
:group-id="groupId"
/>
<div class="clearfix prepend-top-10"> <div class="clearfix prepend-top-10">
<button <button
class="btn btn-success pull-left" class="btn btn-success pull-left"
type="submit" type="submit"
:disabled="title === ''" :disabled="disabled"
ref="submit-button" ref="submit-button"
> >
Submit issue Submit issue
...@@ -110,4 +138,6 @@ export default { ...@@ -110,4 +138,6 @@ export default {
</div> </div>
</form> </form>
</div> </div>
</div>
</template> </template>
...@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({
required: false, required: false,
default: false, default: false,
}, },
groupId: {
type: Number,
required: false,
},
}, },
data() { data() {
return { return {
...@@ -64,7 +68,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -64,7 +68,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.iid}`; let baseUrl = this.issueLinkBase;
if (this.groupId && this.issue.project) {
baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path);
}
return `${baseUrl}/${this.issue.iid}`;
}, },
issueId() { issueId() {
if (this.issue.iid) { if (this.issue.iid) {
...@@ -148,7 +158,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -148,7 +158,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({
class="card-number" class="card-number"
v-if="issueId" v-if="issueId"
> >
{{ issueId }} <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }}
</span> </span>
</h4> </h4>
<div class="card-assignee"> <div class="card-assignee">
......
<script>
/* global ListIssue */
import _ from 'underscore';
import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import Api from '../../api';
export default {
name: 'BoardProjectSelect',
components: {
loadingIcon,
},
props: {
groupId: {
type: Number,
required: true,
default: 0,
},
},
data() {
return {
loading: true,
selectedProject: {},
};
},
computed: {
selectedProjectName() {
return this.selectedProject.name || 'Select a project';
},
},
mounted() {
$(this.$refs.projectsDropdown).glDropdown({
filterable: true,
filterRemote: true,
search: {
fields: ['name_with_namespace'],
},
clicked: ({ $el, e }) => {
e.preventDefault();
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
selectable: true,
data: (term, callback) => {
this.loading = true;
return Api.groupProjects(this.groupId, term, (projects) => {
this.loading = false;
callback(projects);
});
},
renderRow(project) {
return `
<li>
<a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}">
${_.escape(project.name)}
</a>
</li>
`;
},
text: project => project.name,
});
},
};
</script>
<template>
<div>
<label class="label-light prepend-top-10">
Project
</label>
<div
ref="projectsDropdown"
class="dropdown"
>
<button
class="dropdown-menu-toggle wide"
type="button"
data-toggle="dropdown"
aria-expanded="false"
>
{{ selectedProjectName }}
<i
class="fa fa-chevron-down"
aria-hidden="true"
>
</i>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
<div class="dropdown-title">
<span>Projects</span>
<button
aria-label="Close"
type="button"
class="dropdown-title-button dropdown-menu-close"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
>
</i>
</button>
</div>
<div class="dropdown-input">
<input
class="dropdown-input-field"
type="search"
placeholder="Search projects"
/>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-search dropdown-input-search"
>
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</template>
...@@ -24,7 +24,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ ...@@ -24,7 +24,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
}, },
computed: { computed: {
updateUrl() { updateUrl() {
return this.issueUpdate; return this.issueUpdate.replace(':project_path', this.issue.project.path);
}, },
}, },
methods: { methods: {
...@@ -32,17 +32,21 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ ...@@ -32,17 +32,21 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
const issue = this.issue; const issue = this.issue;
const lists = issue.getLists(); const lists = issue.getLists();
const listLabelIds = lists.map(list => list.label.id); const listLabelIds = lists.map(list => list.label.id);
let labelIds = this.issue.labels
let labelIds = issue.labels
.map(label => label.id) .map(label => label.id)
.filter(id => !listLabelIds.includes(id)); .filter(id => !listLabelIds.includes(id));
if (labelIds.length === 0) { if (labelIds.length === 0) {
labelIds = ['']; labelIds = [''];
} }
const data = { const data = {
issue: { issue: {
label_ids: labelIds, label_ids: labelIds,
}, },
}; };
// Post the remove data
Vue.http.patch(this.updateUrl, data).catch(() => { Vue.http.patch(this.updateUrl, data).catch(() => {
Flash(__('Failed to remove issue from board, please try again.')); Flash(__('Failed to remove issue from board, please try again.'));
......
...@@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { ...@@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) { constructor(store, updateUrl = false, cantEdit = []) {
super({ super({
page: 'boards', page: 'boards',
stateFiltersSelector: '.issues-state-filters',
}); });
this.store = store; this.store = store;
......
...@@ -13,6 +13,7 @@ import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import ...@@ -13,6 +13,7 @@ import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import
import './models/issue'; import './models/issue';
import './models/list'; import './models/list';
import './models/milestone'; import './models/milestone';
import './models/project';
import './models/assignee'; import './models/assignee';
import './stores/boards_store'; import './stores/boards_store';
import './stores/modal_store'; import './stores/modal_store';
...@@ -89,7 +90,7 @@ export default () => { ...@@ -89,7 +90,7 @@ export default () => {
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
}, },
mounted () { mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true); this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
this.filterManager.setup(); this.filterManager.setup();
Store.disabled = this.disabled; Store.disabled = this.disabled;
...@@ -179,6 +180,7 @@ export default () => { ...@@ -179,6 +180,7 @@ export default () => {
return { return {
modal: ModalStore.store, modal: ModalStore.store,
store: Store.state, store: Store.state,
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
}; };
}, },
computed: { computed: {
...@@ -232,6 +234,7 @@ export default () => { ...@@ -232,6 +234,7 @@ export default () => {
:class="{ 'disabled': disabled }" :class="{ 'disabled': disabled }"
:title="tooltipTitle" :title="tooltipTitle"
:aria-disabled="disabled" :aria-disabled="disabled"
v-if="canAdminList"
@click="openModal"> @click="openModal">
Add issues Add issues
</button> </button>
......
/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */
/* global DocumentTouch */ /* global DocumentTouch */
import sortableConfig from '../../sortable/sortable_config';
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {}; window.gl.issueBoards = window.gl.issueBoards || {};
...@@ -18,19 +20,14 @@ gl.issueBoards.onEnd = () => { ...@@ -18,19 +20,14 @@ gl.issueBoards.onEnd = () => {
gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
const defaultSortOptions = { const defaultSortOptions = Object.assign({}, sortableConfig, {
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
filter: '.board-delete, .btn', filter: '.board-delete, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0, delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20, scrollSpeed: 20,
onStart: gl.issueBoards.onStart, onStart: gl.issueBoards.onStart,
onEnd: gl.issueBoards.onEnd onEnd: gl.issueBoards.onEnd,
}; });
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
return defaultSortOptions; return defaultSortOptions;
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
/* global ListAssignee */ /* global ListAssignee */
import Vue from 'vue'; import Vue from 'vue';
import IssueProject from './project';
class ListIssue { class ListIssue {
constructor (obj, defaultAvatar) { constructor (obj, defaultAvatar) {
...@@ -23,6 +24,12 @@ class ListIssue { ...@@ -23,6 +24,12 @@ class ListIssue {
this.isLoading = {}; this.isLoading = {};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
if (obj.project) {
this.project = new IssueProject(obj.project);
}
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
...@@ -105,7 +112,8 @@ class ListIssue { ...@@ -105,7 +112,8 @@ class ListIssue {
data.issue.label_ids = ['']; data.issue.label_ids = [''];
} }
return Vue.http.patch(url, data); const projectPath = this.project ? this.project.path : '';
return Vue.http.patch(url.replace(':project_path', projectPath), data);
} }
} }
......
export default class IssueProject {
constructor(obj) {
this.id = obj.id;
this.path = obj.path;
}
}
import UsersSelect from '~/users_select';
import ShortcutsNavigation from '~/shortcuts_navigation';
import initBoards from '~/boards';
document.addEventListener('DOMContentLoaded', () => {
new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initBoards();
});
export default {
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
};
module Boards module Boards
class IssuesController < Boards::ApplicationController class IssuesController < Boards::ApplicationController
include BoardsResponses include BoardsResponses
include ControllerWithCrossProjectAccessCheck
requires_cross_project_access if: -> { board&.group_board? }
before_action :whitelist_query_limiting, only: [:index, :update] before_action :whitelist_query_limiting, only: [:index, :update]
before_action :authorize_read_issue, only: [:index] before_action :authorize_read_issue, only: [:index]
...@@ -64,12 +67,20 @@ module Boards ...@@ -64,12 +67,20 @@ module Boards
end end
def issues_finder def issues_finder
if board.group_board?
IssuesFinder.new(current_user, group_id: board_parent.id)
else
IssuesFinder.new(current_user, project_id: board_parent.id) IssuesFinder.new(current_user, project_id: board_parent.id)
end end
end
def project def project
@project ||= if board.group_board?
Project.find(issue_params[:project_id])
else
board_parent board_parent
end end
end
def move_params def move_params
params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id) params.permit(:board_id, :id, :from_list_id, :to_list_id, :move_before_id, :move_after_id)
......
module BoardsResponses module BoardsResponses
include Gitlab::Utils::StrongMemoize
def board_params
params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
end
def parent
strong_memoize(:parent) do
group? ? group : project
end
end
def boards_path
if group?
group_boards_path(parent)
else
project_boards_path(parent)
end
end
def board_path(board)
if group?
group_board_path(parent, board)
else
project_board_path(parent, board)
end
end
def group?
instance_variable_defined?(:@group)
end
def authorize_read_list def authorize_read_list
authorize_action_for!(board.parent, :read_list) ability = board.group_board? ? :read_group : :read_list
authorize_action_for!(board.parent, ability)
end end
def authorize_read_issue def authorize_read_issue
authorize_action_for!(board.parent, :read_issue) ability = board.group_board? ? :read_group : :read_issue
authorize_action_for!(board.parent, ability)
end end
def authorize_update_issue def authorize_update_issue
...@@ -31,6 +67,10 @@ module BoardsResponses ...@@ -31,6 +67,10 @@ module BoardsResponses
respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
def serialize_as_json(resource)
resource.as_json(only: [:id])
end
def respond_with(resource) def respond_with(resource)
respond_to do |format| respond_to do |format|
format.html format.html
......
class Groups::BoardsController < Groups::ApplicationController
include BoardsResponses
before_action :assign_endpoint_vars
def index
@boards = Boards::ListService.new(group, current_user).execute
respond_with_boards
end
def show
@board = group.boards.find(params[:id])
respond_with_board
end
def assign_endpoint_vars
@boards_endpoint = group_boards_url(group)
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
def serialize_as_json(resource)
resource.as_json(only: [:id])
end
end
...@@ -35,6 +35,8 @@ class Groups::LabelsController < Groups::ApplicationController ...@@ -35,6 +35,8 @@ class Groups::LabelsController < Groups::ApplicationController
def create def create
@label = Labels::CreateService.new(label_params).execute(group: group) @label = Labels::CreateService.new(label_params).execute(group: group)
respond_to do |format|
format.html do
if @label.valid? if @label.valid?
redirect_to group_labels_path(@group) redirect_to group_labels_path(@group)
else else
...@@ -42,6 +44,12 @@ class Groups::LabelsController < Groups::ApplicationController ...@@ -42,6 +44,12 @@ class Groups::LabelsController < Groups::ApplicationController
end end
end end
format.json do
render json: LabelSerializer.new.represent_appearance(@label)
end
end
end
def edit def edit
@previous_labels_path = previous_labels_path @previous_labels_path = previous_labels_path
end end
......
...@@ -17,23 +17,35 @@ module BoardsHelper ...@@ -17,23 +17,35 @@ module BoardsHelper
end end
def build_issue_link_base def build_issue_link_base
if board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
else
project_issues_path(@project) project_issues_path(@project)
end end
end
def board_base_url def board_base_url
if board.group_board?
group_boards_url(@group)
else
project_boards_path(@project) project_boards_path(@project)
end end
end
def multiple_boards_available? def multiple_boards_available?
current_board_parent.multiple_issue_boards_available?(current_user) current_board_parent.multiple_issue_boards_available?
end end
def current_board_path(board) def current_board_path(board)
@current_board_path ||= project_board_path(current_board_parent, board) @current_board_path ||= if board.group_board?
group_board_path(current_board_parent, board)
else
project_board_path(current_board_parent, board)
end
end end
def current_board_parent def current_board_parent
@current_board_parent ||= @project @current_board_parent ||= @group || @project
end end
def can_admin_issue? def can_admin_issue?
...@@ -47,7 +59,8 @@ module BoardsHelper ...@@ -47,7 +59,8 @@ module BoardsHelper
labels: labels_filter_path(true), labels: labels_filter_path(true),
labels_endpoint: @labels_endpoint, labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path, namespace_path: @namespace_path,
project_path: @project&.try(:path) project_path: @project&.path,
group_path: @group&.path
} }
end end
...@@ -59,7 +72,8 @@ module BoardsHelper ...@@ -59,7 +72,8 @@ module BoardsHelper
field_name: 'issue[assignee_ids][]', field_name: 'issue[assignee_ids][]',
first_user: current_user&.username, first_user: current_user&.username,
current_user: 'true', current_user: 'true',
project_id: @project&.try(:id), project_id: @project&.id,
group_id: @group&.id,
null_user: 'true', null_user: 'true',
multi_select: 'true', multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'], 'dropdown-header': dropdown_options[:data][:'dropdown-header'],
......
...@@ -27,7 +27,7 @@ module FormHelper ...@@ -27,7 +27,7 @@ module FormHelper
first_user: current_user&.username, first_user: current_user&.username,
null_user: true, null_user: true,
current_user: true, current_user: true,
project_id: @project.id, project_id: @project&.id,
field_name: 'issue[assignee_ids][]', field_name: 'issue[assignee_ids][]',
default_label: 'Unassigned', default_label: 'Unassigned',
'max-select': 1, 'max-select': 1,
......
...@@ -129,7 +129,7 @@ module GroupsHelper ...@@ -129,7 +129,7 @@ module GroupsHelper
links = [:overview, :group_members] links = [:overview, :group_members]
if can?(current_user, :read_cross_project) if can?(current_user, :read_cross_project)
links += [:activity, :issues, :labels, :milestones, :merge_requests] links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests]
end end
if can?(current_user, :admin_group, @group) if can?(current_user, :admin_group, @group)
......
class Board < ActiveRecord::Base class Board < ActiveRecord::Base
belongs_to :group
belongs_to :project belongs_to :project
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, if: :project_needed? validates :project, presence: true, if: :project_needed?
validates :group, presence: true, unless: :project
def project_needed? def project_needed?
true !group
end end
def parent def parent
project @parent ||= group || project
end end
def group_board? def group_board?
false group_id.present?
end end
def backlog_list def backlog_list
......
...@@ -31,6 +31,7 @@ class Group < Namespace ...@@ -31,6 +31,7 @@ class Group < Namespace
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :boards
has_many :badges, class_name: 'GroupBadge' has_many :badges, class_name: 'GroupBadge'
accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :variables, allow_destroy: true
......
...@@ -35,6 +35,7 @@ class Label < ActiveRecord::Base ...@@ -35,6 +35,7 @@ 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 :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project) def self.prioritized(project)
......
...@@ -222,6 +222,11 @@ class Namespace < ActiveRecord::Base ...@@ -222,6 +222,11 @@ class Namespace < ActiveRecord::Base
has_parent? has_parent?
end end
# Overridden on EE module
def multiple_issue_boards_available?
false
end
def full_path_was def full_path_was
if parent_id_was.nil? if parent_id_was.nil?
path_was path_was
......
...@@ -1684,8 +1684,9 @@ class Project < ActiveRecord::Base ...@@ -1684,8 +1684,9 @@ class Project < ActiveRecord::Base
end end
end end
def multiple_issue_boards_available?(user) # Overridden on EE module
feature_available?(:multiple_issue_boards, user) def multiple_issue_boards_available?
false
end end
def issue_board_milestone_available?(user = nil) def issue_board_milestone_available?(user = nil)
......
...@@ -48,7 +48,12 @@ class GroupPolicy < BasePolicy ...@@ -48,7 +48,12 @@ class GroupPolicy < BasePolicy
rule { has_access }.enable :read_namespace rule { has_access }.enable :read_namespace
rule { developer }.enable :admin_milestones rule { developer }.enable :admin_milestones
rule { reporter }.enable :admin_label
rule { reporter }.policy do
enable :admin_label
enable :admin_list
enable :admin_issue
end
rule { master }.policy do rule { master }.policy do
enable :create_projects enable :create_projects
......
...@@ -40,8 +40,12 @@ module Boards ...@@ -40,8 +40,12 @@ module Boards
end end
def set_parent def set_parent
if parent.is_a?(Group)
params[:group_id] = parent.id
else
params[:project_id] = parent.id params[:project_id] = parent.id
end end
end
def set_state def set_state
params[:state] = list && list.closed? ? 'closed' : 'opened' params[:state] = list && list.closed? ? 'closed' : 'opened'
......
...@@ -60,8 +60,10 @@ module Boards ...@@ -60,8 +60,10 @@ module Boards
label_ids = label_ids =
if moving_to_list.movable? if moving_to_list.movable?
moving_from_list.label_id moving_from_list.label_id
elsif board.group_board?
::Label.on_group_boards(parent.id).pluck(:label_id)
else else
Label.on_project_boards(parent.id).pluck(:label_id) ::Label.on_project_boards(parent.id).pluck(:label_id)
end end
Array(label_ids).compact Array(label_ids).compact
......
...@@ -12,8 +12,12 @@ module Boards ...@@ -12,8 +12,12 @@ module Boards
private private
def available_labels_for(board) def available_labels_for(board)
if board.group_board?
parent.labels
else
LabelsFinder.new(current_user, project_id: parent.id).execute LabelsFinder.new(current_user, project_id: parent.id).execute
end end
end
def next_position(board) def next_position(board)
max_position = board.lists.movable.maximum(:position) max_position = board.lists.movable.maximum(:position)
......
= render "shared/boards/show", board: @boards.first
= render "shared/boards/show", board: @board, group: true
- issues_count = group_issues_count(state: 'opened') - issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened')
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index'] - issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll .nav-sidebar-inner-scroll
...@@ -51,12 +51,19 @@ ...@@ -51,12 +51,19 @@
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
#{ _('Issues') } #{ _('Issues') }
%span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count) %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count)
%li.divider.fly-out-top-item %li.divider.fly-out-top-item
= nav_link(path: 'groups#issues', html_options: { class: 'home' }) do = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
= link_to issues_group_path(@group), title: 'List' do = link_to issues_group_path(@group), title: 'List' do
%span %span
List List
- if group_sidebar_link?(:boards)
= nav_link(path: ['boards#index', 'boards#show']) do
= link_to group_boards_path(@group), title: boards_link_text do
%span
= boards_link_text
- if group_sidebar_link?(:labels) - if group_sidebar_link?(:labels)
= nav_link(path: 'labels#index') do = nav_link(path: 'labels#index') do
= link_to group_labels_path(@group), title: 'Labels' do = link_to group_labels_path(@group), title: 'Labels' do
......
- board = local_assigns.fetch(:board, nil)
- group = local_assigns.fetch(:group, false)
- @no_breadcrumb_container = true - @no_breadcrumb_container = true
- @no_container = true - @no_container = true
- @content_class = "issue-boards-content" - @content_class = "issue-boards-content"
...@@ -27,7 +29,7 @@ ...@@ -27,7 +29,7 @@
":root-path" => "rootPath", ":root-path" => "rootPath",
":board-id" => "boardId", ":board-id" => "boardId",
":key" => "_uid" } ":key" => "_uid" }
= render "shared/boards/components/sidebar" = render "shared/boards/components/sidebar", group: group
- if @project - if @project
%board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project), %board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path, "milestone-path" => milestones_filter_dropdown_path,
......
...@@ -42,6 +42,7 @@ ...@@ -42,6 +42,7 @@
":disabled" => "disabled", ":disabled" => "disabled",
":issue-link-base" => "issueLinkBase", ":issue-link-base" => "issueLinkBase",
":root-path" => "rootPath", ":root-path" => "rootPath",
":groupId" => ((current_board_parent.id if @group) || 'null'),
"ref" => "board-list" } "ref" => "board-list" }
- if can?(current_user, :admin_list, current_board_parent) - 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"' }
...@@ -112,6 +112,7 @@ ...@@ -112,6 +112,7 @@
- if can?(current_user, :admin_label, board.parent) - 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 - if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- elsif type != :boards_modal - elsif type != :boards_modal
= render 'shared/sort_dropdown' = render 'shared/sort_dropdown'
---
title: Add one group board to Libre
merge_request:
author:
type: added
...@@ -56,6 +56,9 @@ constraints(GroupUrlConstrainer.new) do ...@@ -56,6 +56,9 @@ constraints(GroupUrlConstrainer.new) do
get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} } get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} }
end end
end end
# On CE only index and show actions are needed
resources :boards, only: [:index, :show]
end end
scope(path: '*id', scope(path: '*id',
......
...@@ -381,7 +381,8 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -381,7 +381,8 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes' get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
resources :boards, only: [:index, :show, :create, :update, :destroy] # On CE only index and show are needed
resources :boards, only: [:index, :show]
resources :todos, only: [:create] resources :todos, only: [:create]
......
class AddGroupIdToBoards < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
return if group_id_exists?
add_column :boards, :group_id, :integer
add_foreign_key :boards, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :boards, :group_id
change_column_null :boards, :project_id, true
end
def down
return unless group_id_exists?
remove_foreign_key :boards, column: :group_id
remove_index :boards, :group_id if index_exists? :boards, :group_id
remove_column :boards, :group_id
execute "DELETE from boards WHERE project_id IS NULL"
change_column_null :boards, :project_id, false
end
private
def group_id_exists?
column_exists?(:boards, :group_id)
end
end
...@@ -197,11 +197,13 @@ ActiveRecord::Schema.define(version: 20180306074045) do ...@@ -197,11 +197,13 @@ ActiveRecord::Schema.define(version: 20180306074045) do
add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree
create_table "boards", force: :cascade do |t| create_table "boards", force: :cascade do |t|
t.integer "project_id", null: false t.integer "project_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "group_id"
end end
add_index "boards", ["group_id"], name: "index_boards_on_group_id", using: :btree
add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree
create_table "broadcast_messages", force: :cascade do |t| create_table "broadcast_messages", force: :cascade do |t|
...@@ -1986,6 +1988,7 @@ ActiveRecord::Schema.define(version: 20180306074045) do ...@@ -1986,6 +1988,7 @@ ActiveRecord::Schema.define(version: 20180306074045) do
add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "projects", on_delete: :cascade add_foreign_key "badges", "projects", on_delete: :cascade
add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade
......
...@@ -77,6 +77,7 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ...@@ -77,6 +77,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)
- [Project issue Board](user/project/issue_board.md) - [Project issue Board](user/project/issue_board.md)
- [Group Issue Board](user/project/issue_board.md#group-issue-board)
- [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)
......
...@@ -28,6 +28,7 @@ following locations: ...@@ -28,6 +28,7 @@ following locations:
- [Group Members](members.md) - [Group Members](members.md)
- [Issues](issues.md) - [Issues](issues.md)
- [Issue Boards](boards.md) - [Issue Boards](boards.md)
- [Group Issue Boards](group_boards.md)
- [Jobs](jobs.md) - [Jobs](jobs.md)
- [Keys](keys.md) - [Keys](keys.md)
- [Labels](labels.md) - [Labels](labels.md)
......
# Group Issue Boards API
Every API call to group boards must be authenticated.
If a user is not a member of a group and the group is private, a `GET`
request will result in `404` status code.
## Group Board
Lists Issue Boards in the given group.
```
GET /groups/:id/boards
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards
```
Example response:
```json
[
{
"id": 1,
"group_id": 5,
"lists" : [
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
},
{
"id" : 2,
"label" : {
"name" : "Ready",
"color" : "#FF0000",
"description" : null
},
"position" : 2
},
{
"id" : 3,
"label" : {
"name" : "Production",
"color" : "#FF5F00",
"description" : null
},
"position" : 3
}
]
}
]
```
## Single board
Gets a single board.
```
GET /groups/:id/boards/:board_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1
```
Example response:
```json
{
"id": 1,
"group_id": 5,
"lists" : [
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
},
{
"id" : 2,
"label" : {
"name" : "Ready",
"color" : "#FF0000",
"description" : null
},
"position" : 2
},
{
"id" : 3,
"label" : {
"name" : "Production",
"color" : "#FF5F00",
"description" : null
},
"position" : 3
}
]
}
```
## List board lists
Get a list of the board's lists.
Does not include `backlog` and `closed` lists
```
GET /groups/:id/boards/:board_id/lists
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists
```
Example response:
```json
[
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
},
{
"id" : 2,
"label" : {
"name" : "Ready",
"color" : "#FF0000",
"description" : null
},
"position" : 2
},
{
"id" : 3,
"label" : {
"name" : "Production",
"color" : "#FF5F00",
"description" : null
},
"position" : 3
}
]
```
## Single board list
Get a single board list.
```
GET /groups/:id/boards/:board_id/lists/:list_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
| `list_id` | integer | yes | The ID of a board's list |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists/1
```
Example response:
```json
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
}
```
## New board list
Creates a new Issue Board list.
```
POST /groups/:id/boards/:board_id/lists
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
| `label_id` | integer | yes | The ID of a label |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists?label_id=5
```
Example response:
```json
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
}
```
## Edit board list
Updates an existing Issue Board list. This call is used to change list position.
```
PUT /groups/:id/boards/:board_id/lists/:list_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
| `list_id` | integer | yes | The ID of a board's list |
| `position` | integer | yes | The position of the list |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/group/5/boards/1/lists/1?position=2
```
Example response:
```json
{
"id" : 1,
"label" : {
"name" : "Testing",
"color" : "#F0AD4E",
"description" : null
},
"position" : 1
}
```
## Delete a board list
Only for admins and group owners. Soft deletes the board list in question.
```
DELETE /groups/:id/boards/:board_id/lists/:list_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board |
| `list_id` | integer | yes | The ID of a board's list |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/5/boards/1/lists/1
```
...@@ -235,6 +235,16 @@ to another list the label changes and a system not is recorded. ...@@ -235,6 +235,16 @@ to another list the label changes and a system not is recorded.
[Developers and up](../permissions.md) can use all the functionality of the [Developers and up](../permissions.md) can use all the functionality of the
Issue Board, that is create/delete lists and drag issues around. Issue Board, that is create/delete lists and drag issues around.
## Group Issue Board
>Introduced in GitLab 10.6
Group issue board is analogous to project-level issue board and it is accessible at the group
navigation level. A group-level issue board allows you to view all issues from all projects in that group
(currently, it does not see issues from projects in subgroups). Similarly, you can only filter by group labels for these
boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
group-level objects are available.
## Tips ## Tips
A few things to remember: A few things to remember:
......
...@@ -121,6 +121,7 @@ module API ...@@ -121,6 +121,7 @@ module API
mount ::API::Events mount ::API::Events
mount ::API::Features mount ::API::Features
mount ::API::Files mount ::API::Files
mount ::API::GroupBoards
mount ::API::Groups mount ::API::Groups
mount ::API::GroupMilestones mount ::API::GroupMilestones
mount ::API::Internal mount ::API::Internal
......
module API
class GroupBoards < Grape::API
include BoardsResponses
include PaginationParams
before do
authenticate!
end
helpers do
def board_parent
user_group
end
end
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
segment ':id/boards' do
desc 'Find a group board' do
detail 'This feature was introduced in 10.6'
success ::API::Entities::Board
end
get '/:board_id' do
present board, with: ::API::Entities::Board
end
desc 'Get all group boards' do
detail 'This feature was introduced in 10.6'
success Entities::Board
end
params do
use :pagination
end
get '/' do
present paginate(board_parent.boards), with: Entities::Board
end
end
params do
requires :board_id, type: Integer, desc: 'The ID of a board'
end
segment ':id/boards/:board_id' do
desc 'Get the lists of a group board' do
detail 'Does not include backlog and closed lists. This feature was introduced in 10.6'
success Entities::List
end
params do
use :pagination
end
get '/lists' do
present paginate(board_lists), with: Entities::List
end
desc 'Get a list of a group board' do
detail 'This feature was introduced in 10.6'
success Entities::List
end
params do
requires :list_id, type: Integer, desc: 'The ID of a list'
end
get '/lists/:list_id' do
present board_lists.find(params[:list_id]), with: Entities::List
end
desc 'Create a new board list' do
detail 'This feature was introduced in 10.6'
success Entities::List
end
params do
requires :label_id, type: Integer, desc: 'The ID of an existing label'
end
post '/lists' do
unless available_labels_for(board_parent).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize!(:admin_list, user_group)
create_list
end
desc 'Moves a board list to a new position' do
detail 'This feature was introduced in 10.6'
success Entities::List
end
params do
requires :list_id, type: Integer, desc: 'The ID of a list'
requires :position, type: Integer, desc: 'The position of the list'
end
put '/lists/:list_id' do
list = board_lists.find(params[:list_id])
authorize!(:admin_list, user_group)
move_list(list)
end
desc 'Delete a board list' do
detail 'This feature was introduced in 10.6'
success Entities::List
end
params do
requires :list_id, type: Integer, desc: 'The ID of a board list'
end
delete "/lists/:list_id" do
authorize!(:admin_list, user_group)
list = board_lists.find(params[:list_id])
destroy_list(list)
end
end
end
end
end
require 'spec_helper'
describe Groups::BoardsController do
let(:group) { create(:group) }
let(:user) { create(:user) }
before do
group.add_master(user)
sign_in(user)
end
describe 'GET index' do
it 'creates a new board when group does not have one' do
expect { list_boards }.to change(group.boards, :count).by(1)
end
context 'when format is HTML' do
it 'renders template' do
list_boards
expect(response).to render_template :index
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
list_boards
expect(response).to have_gitlab_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'return an array with one group board' do
create(:board, group: group)
list_boards format: :json
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('boards')
expect(parsed_response.length).to eq 1
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
list_boards format: :json
expect(response).to have_gitlab_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end
end
def list_boards(format: :html)
get :index, group_id: group, format: format
end
end
describe 'GET show' do
let!(:board) { create(:board, group: group) }
context 'when format is HTML' do
it 'renders template' do
read_board board: board
expect(response).to render_template :show
expect(response.content_type).to eq 'text/html'
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board
expect(response).to have_gitlab_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'returns project board' do
read_board board: board, format: :json
expect(response).to match_response_schema('board')
end
context 'with unauthorized user' do
before do
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(false)
end
it 'returns a not found 404 response' do
read_board board: board, format: :json
expect(response).to have_gitlab_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end
end
context 'when board does not belong to group' do
it 'returns a not found 404 response' do
another_board = create(:board)
read_board board: another_board
expect(response).to have_gitlab_http_status(404)
end
end
def read_board(board:, format: :html)
get :show, group_id: group,
id: board.to_param,
format: format
end
end
end
FactoryBot.define do FactoryBot.define do
factory :board do factory :board do
project transient do
project nil
group nil
project_id nil
group_id nil
parent nil
end
after(:build, :stub) do |board, evaluator|
if evaluator.group
board.group = evaluator.group
elsif evaluator.group_id
board.group_id = evaluator.group_id
elsif evaluator.project
board.project = evaluator.project
elsif evaluator.project_id
board.project_id = evaluator.project_id
elsif evaluator.parent
id = evaluator.parent.id
evaluator.parent.is_a?(Group) ? board.group_id = id : evaluator.project_id = id
else
board.project = create(:project, :empty_repo)
end
end
after(:create) do |board| after(:create) do |board|
board.lists.create(list_type: :closed) board.lists.create(list_type: :closed)
......
require 'spec_helper' require 'spec_helper'
describe BoardsHelper do describe BoardsHelper do
describe '#build_issue_link_base' do
context 'project board' do
it 'returns correct path for project board' do
@project = create(:project)
@board = create(:board, project: @project)
expect(build_issue_link_base).to eq("/#{@project.namespace.path}/#{@project.path}/issues")
end
end
context 'group board' do
let(:base_group) { create(:group, path: 'base') }
it 'returns correct path for base group' do
@board = create(:board, group: base_group)
expect(build_issue_link_base).to eq('/base/:project_path/issues')
end
it 'returns correct path for subgroup' do
subgroup = create(:group, parent: base_group, path: 'sub')
@board = create(:board, group: subgroup)
expect(build_issue_link_base).to eq('/base/sub/:project_path/issues')
end
end
end
describe '#board_data' do describe '#board_data' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
......
...@@ -137,6 +137,27 @@ describe('Api', () => { ...@@ -137,6 +137,27 @@ describe('Api', () => {
done(); done();
}); });
}); });
it('creates a group label', (done) => {
const namespace = 'group/subgroup';
const labelData = { some: 'data' };
const expectedUrl = `${dummyUrlRoot}/groups/${namespace}/-/labels`;
const expectedData = {
label: labelData,
};
mock.onPost(expectedUrl).reply((config) => {
expect(config.data).toBe(JSON.stringify(expectedData));
return [200, {
name: 'test',
}];
});
Api.newLabel(namespace, undefined, labelData, (response) => {
expect(response.name).toBe('test');
done();
});
});
}); });
describe('groupProjects', () => { describe('groupProjects', () => {
......
require 'spec_helper'
describe API::GroupBoards do
set(:user) { create(:user) }
set(:non_member) { create(:user) }
set(:guest) { create(:user) }
set(:admin) { create(:user, :admin) }
set(:board_parent) { create(:group, :public) }
before do
board_parent.add_owner(user)
end
set(:project) { create(:project, :public, namespace: board_parent ) }
set(:dev_label) do
create(:group_label, title: 'Development', color: '#FFAABB', group: board_parent)
end
set(:test_label) do
create(:group_label, title: 'Testing', color: '#FFAACC', group: board_parent)
end
set(:ux_label) do
create(:group_label, title: 'UX', color: '#FF0000', group: board_parent)
end
set(:dev_list) do
create(:list, label: dev_label, position: 1)
end
set(:test_list) do
create(:list, label: test_label, position: 2)
end
set(:milestone) { create(:milestone, group: board_parent) }
set(:board_label) { create(:group_label, group: board_parent) }
set(:board) { create(:board, group: board_parent, lists: [dev_list, test_list]) }
it_behaves_like 'group and project boards', "/groups/:id/boards", false
describe 'POST /groups/:id/boards/lists' do
let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
it 'does not create lists for child project labels' do
project_label = create(:label, project: project)
post api(url, user), label_id: project_label.id
expect(response).to have_gitlab_http_status(400)
end
end
end
...@@ -2,34 +2,20 @@ require 'spec_helper' ...@@ -2,34 +2,20 @@ require 'spec_helper'
describe Boards::CreateService do describe Boards::CreateService do
describe '#execute' do describe '#execute' do
let(:project) { create(:project) } context 'when board parent is a project' do
let(:parent) { create(:project) }
subject(:service) { described_class.new(project, double) } subject(:service) { described_class.new(parent, double) }
context 'when project does not have a board' do it_behaves_like 'boards create service'
it 'creates a new board' do
expect { service.execute }.to change(Board, :count).by(1)
end end
it 'creates the default lists' do context 'when board parent is a group' do
board = service.execute let(:parent) { create(:group) }
expect(board.lists.size).to eq 2 subject(:service) { described_class.new(parent, double) }
expect(board.lists.first).to be_backlog
expect(board.lists.last).to be_closed
end
end
context 'when project has a board' do
before do
create(:board, project: project)
end
it 'does not create a new board' do
expect(service).to receive(:can_create_board?) { false }
expect { service.execute }.not_to change(project.boards, :count) it_behaves_like 'boards create service'
end
end end
end end
end end
...@@ -2,10 +2,14 @@ require 'spec_helper' ...@@ -2,10 +2,14 @@ require 'spec_helper'
describe Boards::Issues::ListService do describe Boards::Issues::ListService do
describe '#execute' do describe '#execute' do
context 'when parent is a project' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:m1) { create(:milestone, project: project) }
let(:m2) { create(:milestone, project: project) }
let(:bug) { create(:label, project: project, name: 'Bug') } let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') } let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') } let(:testing) { create(:label, project: project, name: 'Testing') }
...@@ -18,14 +22,14 @@ describe Boards::Issues::ListService do ...@@ -18,14 +22,14 @@ describe Boards::Issues::ListService do
let!(:list2) { create(:list, board: board, label: testing, position: 1) } let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) } let!(:closed) { create(:closed_list, board: board) }
let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) } let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) } let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
let!(:reopened_issue1) { create(:issue, :opened, project: project) } let!(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Issue 3' ) }
let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) } let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) } let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) } let!(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) }
let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) } let!(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) }
let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) } let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) } let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
...@@ -33,67 +37,68 @@ describe Boards::Issues::ListService do ...@@ -33,67 +37,68 @@ describe Boards::Issues::ListService do
let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) } let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) }
let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development]) } let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development]) }
let(:parent) { project }
before do before do
project.add_developer(user) project.add_developer(user)
end end
it 'delegates search to IssuesFinder' do it_behaves_like 'issues list service'
params = { board_id: board.id, id: list1.id }
expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
described_class.new(project, user, params).execute
end end
context 'issues are ordered by priority' do context 'when parent is a group' do
it 'returns opened issues when list_id is missing' do let(:user) { create(:user) }
params = { board_id: board.id } let(:group) { create(:group) }
let(:project) { create(:project, :empty_repo, namespace: group) }
issues = described_class.new(project, user, params).execute let(:project1) { create(:project, :empty_repo, namespace: group) }
let(:board) { create(:board, group: group) }
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns opened issues when listing issues from Backlog' do let(:m1) { create(:milestone, group: group) }
params = { board_id: board.id, id: backlog.id } let(:m2) { create(:milestone, group: group) }
issues = described_class.new(project, user, params).execute let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let(:testing) { create(:group_label, group: group, name: 'Testing') }
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] let(:p1) { create(:group_label, title: 'P1', group: group) }
end let(:p2) { create(:group_label, title: 'P2', group: group) }
let(:p3) { create(:group_label, title: 'P3', group: group) }
it 'returns closed issues when listing issues from Closed' do let(:p1_project) { create(:label, title: 'P1_project', project: project, priority: 1) }
params = { board_id: board.id, id: closed.id } let(:p2_project) { create(:label, title: 'P2_project', project: project, priority: 2) }
let(:p3_project) { create(:label, title: 'P3_project', project: project, priority: 3) }
issues = described_class.new(project, user, params).execute let(:p1_project1) { create(:label, title: 'P1_project1', project: project1, priority: 1) }
let(:p2_project1) { create(:label, title: 'P2_project1', project: project1, priority: 2) }
let(:p3_project1) { create(:label, title: 'P3_project1', project: project1, priority: 3) }
expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1] let!(:backlog) { create(:backlog_list, board: board) }
end let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) }
it 'returns opened issues that have label list applied when listing issues from a label list' do let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
params = { board_id: board.id, id: list1.id } let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2, p2_project]) }
let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Issue 3', closed_at: Time.now ) }
issues = described_class.new(project, user, params).execute let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, p2_project, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
let!(:list1_issue3) { create(:labeled_issue, project: project1, milestone: m1, labels: [development, p1, p1_project1]) }
let!(:list2_issue1) { create(:labeled_issue, project: project1, milestone: m1, labels: [testing]) }
expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2] let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
end let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3, p3_project]) }
end let!(:closed_issue3) { create(:issue, :closed, project: project1) }
let!(:closed_issue4) { create(:labeled_issue, :closed, project: project1, labels: [p1, p1_project1]) }
let!(:closed_issue5) { create(:labeled_issue, :closed, project: project1, labels: [development]) }
context 'with list that does not belong to the board' do let(:parent) { group }
it 'raises an error' do
list = create(:list)
service = described_class.new(project, user, board_id: board.id, id: list.id)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) before do
end group.add_developer(user)
end end
context 'with invalid list id' do it_behaves_like 'issues list service'
it 'raises an error' do
service = described_class.new(project, user, board_id: board.id, id: nil)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end end
end end
end end
...@@ -2,108 +2,53 @@ require 'spec_helper' ...@@ -2,108 +2,53 @@ require 'spec_helper'
describe Boards::Issues::MoveService do describe Boards::Issues::MoveService do
describe '#execute' do describe '#execute' do
context 'when parent is a project' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board1) { create(:board, project: project) } let(:board1) { create(:board, project: project) }
let(:board2) { create(:board, project: project) }
let(:bug) { create(:label, project: project, name: 'Bug') } let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') } let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') } let(:testing) { create(:label, project: project, name: 'Testing') }
let(:regression) { create(:label, project: project, name: 'Regression') }
let!(:list1) { create(:list, board: board1, label: development, position: 0) } let!(:list1) { create(:list, board: board1, label: development, position: 0) }
let!(:list2) { create(:list, board: board1, label: testing, position: 1) } let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board1) } let!(:closed) { create(:closed_list, board: board1) }
before do let(:parent) { project }
project.add_developer(user)
end
context 'when moving an issue between lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
it 'delegates the label changes to Issues::UpdateService' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end
it 'removes the label from the list it came from and adds the label of the list it goes to' do
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, testing)
end
end
context 'when moving to closed' do
let(:board2) { create(:board, project: project) }
let(:regression) { create(:label, project: project, name: 'Regression') }
let!(:list3) { create(:list, board: board2, label: regression, position: 1) }
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) }
let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: closed.id } }
it 'delegates the close proceedings to Issues::CloseService' do
expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end
it 'removes all list-labels from project boards and close the issue' do
described_class.new(project, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug) before do
expect(issue).to be_closed parent.add_developer(user)
end
end end
context 'when moving from closed' do it_behaves_like 'issues move service'
let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let(:params) { { board_id: board1.id, from_list_id: closed.id, to_list_id: list2.id } }
it 'delegates the re-open proceedings to Issues::ReopenService' do
expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
described_class.new(project, user, params).execute(issue)
end end
it 'adds the label of the list it goes to and reopen the issue' do context 'when parent is a group' do
described_class.new(project, user, params).execute(issue) let(:user) { create(:user) }
issue.reload let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
expect(issue.labels).to contain_exactly(bug, testing) let(:board1) { create(:board, group: group) }
expect(issue).to be_opened let(:board2) { create(:board, group: group) }
end
end
context 'when moving to same list' do let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } let(:development) { create(:group_label, group: group, name: 'Development') }
let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) } let(:testing) { create(:group_label, group: group, name: 'Testing') }
let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) } let(:regression) { create(:group_label, group: group, name: 'Regression') }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
it 'returns false' do let!(:list1) { create(:list, board: board1, label: development, position: 0) }
expect(described_class.new(project, user, params).execute(issue)).to eq false let!(:list2) { create(:list, board: board1, label: testing, position: 1) }
end let!(:closed) { create(:closed_list, board: board1) }
it 'keeps issues labels' do
described_class.new(project, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, development) let(:parent) { group }
end
it 'sorts issues' do before do
[issue, issue1, issue2].each do |issue| parent.add_developer(user)
issue.move_to_end && issue.save!
end end
params.merge!(move_after_id: issue1.id, move_before_id: issue2.id) it_behaves_like 'issues move service'
described_class.new(project, user, params).execute(issue)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
end end
end end
end end
...@@ -2,36 +2,20 @@ require 'spec_helper' ...@@ -2,36 +2,20 @@ require 'spec_helper'
describe Boards::ListService do describe Boards::ListService do
describe '#execute' do describe '#execute' do
let(:project) { create(:project) } context 'when board parent is a project' do
let(:parent) { create(:project) }
subject(:service) { described_class.new(project, double) } subject(:service) { described_class.new(parent, double) }
context 'when project does not have a board' do it_behaves_like 'boards list service'
it 'creates a new project board' do
expect { service.execute }.to change(project.boards, :count).by(1)
end end
it 'delegates the project board creation to Boards::CreateService' do context 'when board parent is a group' do
expect_any_instance_of(Boards::CreateService).to receive(:execute).once let(:parent) { create(:group) }
service.execute subject(:service) { described_class.new(parent, double) }
end
end
context 'when project has a board' do
before do
create(:board, project: project)
end
it 'does not create a new board' do
expect { service.execute }.not_to change(project.boards, :count)
end
end
it 'returns project boards' do
board = create(:board, project: project)
expect(service.execute).to match_array [board] it_behaves_like 'boards list service'
end end
end end
end end
...@@ -2,15 +2,13 @@ require 'spec_helper' ...@@ -2,15 +2,13 @@ require 'spec_helper'
describe Boards::Lists::CreateService do describe Boards::Lists::CreateService do
describe '#execute' do describe '#execute' do
let(:project) { create(:project) } shared_examples 'creating board lists' do
let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') }
subject(:service) { described_class.new(project, user, label_id: label.id) } subject(:service) { described_class.new(parent, user, label_id: label.id) }
before do before do
project.add_developer(user) parent.add_developer(user)
end end
context 'when board lists is empty' do context 'when board lists is empty' do
...@@ -51,13 +49,30 @@ describe Boards::Lists::CreateService do ...@@ -51,13 +49,30 @@ describe Boards::Lists::CreateService do
end end
end end
context 'when provided label does not belongs to the project' do context 'when provided label does not belongs to the parent' do
it 'raises an error' do it 'raises an error' do
label = create(:label, name: 'in-development') label = create(:label, name: 'in-development')
service = described_class.new(project, user, label_id: label.id) service = described_class.new(parent, user, label_id: label.id)
expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound) expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
end end
context 'when board parent is a project' do
let(:parent) { create(:project) }
let(:board) { create(:board, project: parent) }
let(:label) { create(:label, project: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end
context 'when board parent is a group' do
let(:parent) { create(:group) }
let(:board) { create(:board, group: parent) }
let(:label) { create(:group_label, group: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end
end
end end
...@@ -2,37 +2,24 @@ require 'spec_helper' ...@@ -2,37 +2,24 @@ require 'spec_helper'
describe Boards::Lists::DestroyService do describe Boards::Lists::DestroyService do
describe '#execute' do describe '#execute' do
context 'when board parent is a project' 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) }
context 'when list type is label' do let(:parent) { project }
it 'removes list from board' do
list = create(:list, board: board)
service = described_class.new(project, user)
expect { service.execute(list) }.to change(board.lists, :count).by(-1) it_behaves_like 'lists destroy service'
end end
it 'decrements position of higher lists' do context 'when board parent is a group' do
development = create(:list, board: board, position: 0) let(:group) { create(:group) }
review = create(:list, board: board, position: 1) let(:board) { create(:board, group: group) }
staging = create(:list, board: board, position: 2) let(:user) { create(:user) }
closed = board.closed_list
described_class.new(project, user).execute(development)
expect(review.reload.position).to eq 0
expect(staging.reload.position).to eq 1
expect(closed.reload.position).to be_nil
end
end
it 'does not remove list from board when list type is closed' do let(:parent) { group }
list = board.closed_list
service = described_class.new(project, user)
expect { service.execute(list) }.not_to change(board.lists, :count) it_behaves_like 'lists destroy service'
end end
end end
end end
require 'spec_helper' require 'spec_helper'
describe Boards::Lists::ListService do describe Boards::Lists::ListService do
describe '#execute' do
context 'when board parent is a project' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:label) { create(:label, project: project) } let(:label) { create(:label, project: project) }
let!(:list) { create(:list, board: board, label: label) } let!(:list) { create(:list, board: board, label: label) }
let(:service) { described_class.new(project, double) } let(:service) { described_class.new(project, double) }
describe '#execute' do it_behaves_like 'lists list service'
context 'when the board has a backlog list' do
let!(:backlog_list) { create(:backlog_list, board: board) }
it 'does not create a backlog list' do
expect { service.execute(board) }.not_to change(board.lists, :count)
end end
it "returns board's lists" do context 'when board parent is a group' do
expect(service.execute(board)).to eq [backlog_list, list, board.closed_list] let(:group) { create(:group) }
end let(:board) { create(:board, group: group) }
end let(:label) { create(:group_label, group: group) }
let!(:list) { create(:list, board: board, label: label) }
context 'when the board does not have a backlog list' do let(:service) { described_class.new(group, double) }
it 'creates a backlog list' do
expect { service.execute(board) }.to change(board.lists, :count).by(1)
end
it "returns board's lists" do it_behaves_like 'lists list service'
expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
end
end end
end end
end end
...@@ -2,100 +2,24 @@ require 'spec_helper' ...@@ -2,100 +2,24 @@ require 'spec_helper'
describe Boards::Lists::MoveService do describe Boards::Lists::MoveService do
describe '#execute' do describe '#execute' do
context 'when board parent is a project' 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) }
let!(:planning) { create(:list, board: board, position: 0) } let(:parent) { project }
let!(:development) { create(:list, board: board, position: 1) }
let!(:review) { create(:list, board: board, position: 2) }
let!(:staging) { create(:list, board: board, position: 3) }
let!(:closed) { create(:closed_list, board: board) }
context 'when list type is set to label' do it_behaves_like 'lists move service'
it 'keeps position of lists when new position is nil' do
service = described_class.new(project, user, position: nil)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to old position' do
service = described_class.new(project, user, position: planning.position)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is negative' do
service = described_class.new(project, user, position: -1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to number of labels lists' do
service = described_class.new(project, user, position: board.lists.label.size)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is greater than number of labels lists' do
service = described_class.new(project, user, position: board.lists.label.size + 1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'increments position of intermediate lists when new positon is equal to first position' do
service = described_class.new(project, user, position: 0)
service.execute(staging)
expect(current_list_positions).to eq [1, 2, 3, 0]
end
it 'decrements position of intermediate lists when new positon is equal to last position' do
service = described_class.new(project, user, position: board.lists.label.last.position)
service.execute(planning)
expect(current_list_positions).to eq [3, 0, 1, 2]
end end
it 'decrements position of intermediate lists when new position is greater than old position' do context 'when board parent is a group' do
service = described_class.new(project, user, position: 2) let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
service.execute(planning) let(:user) { create(:user) }
expect(current_list_positions).to eq [2, 0, 1, 3]
end
it 'increments position of intermediate lists when new position is lower than old position' do
service = described_class.new(project, user, position: 1)
service.execute(staging)
expect(current_list_positions).to eq [0, 2, 3, 1]
end
end
it 'keeps position of lists when list type is closed' do
service = described_class.new(project, user, position: 2)
service.execute(closed) let(:parent) { group }
expect(current_list_positions).to eq [0, 1, 2, 3] it_behaves_like 'lists move service'
end end
end end
def current_list_positions
[planning, development, review, staging].map { |list| list.reload.position }
end
end end
shared_examples 'boards create service' do
context 'when parent does not have a board' do
it 'creates a new board' do
expect { service.execute }.to change(Board, :count).by(1)
end
it 'creates the default lists' do
board = service.execute
expect(board.lists.size).to eq 2
expect(board.lists.first).to be_backlog
expect(board.lists.last).to be_closed
end
end
context 'when parent has a board' do
before do
create(:board, parent: parent)
end
it 'does not create a new board' do
expect(service).to receive(:can_create_board?) { false }
expect { service.execute }.not_to change(parent.boards, :count)
end
end
end
shared_examples 'boards list service' do
context 'when parent does not have a board' do
it 'creates a new parent board' do
expect { service.execute }.to change(parent.boards, :count).by(1)
end
it 'delegates the parent board creation to Boards::CreateService' do
expect_any_instance_of(Boards::CreateService).to receive(:execute).once
service.execute
end
end
context 'when parent has a board' do
before do
create(:board, parent: parent)
end
it 'does not create a new board' do
expect { service.execute }.not_to change(parent.boards, :count)
end
end
it 'returns parent boards' do
board = create(:board, parent: parent)
expect(service.execute).to eq [board]
end
end
shared_examples 'issues list service' do
it 'delegates search to IssuesFinder' do
params = { board_id: board.id, id: list1.id }
expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original
described_class.new(parent, user, params).execute
end
context 'issues are ordered by priority' do
it 'returns opened issues when list_id is missing' do
params = { board_id: board.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns opened issues when listing issues from Backlog' do
params = { board_id: board.id, id: backlog.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns closed issues when listing issues from Closed' do
params = { board_id: board.id, id: closed.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1]
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
params = { board_id: board.id, id: list1.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
end
end
context 'with list that does not belong to the board' do
it 'raises an error' do
list = create(:list)
service = described_class.new(parent, user, board_id: board.id, id: list.id)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid list id' do
it 'raises an error' do
service = described_class.new(parent, user, board_id: board.id, id: nil)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
shared_examples 'issues move service' do
context 'when moving an issue between lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
it 'delegates the label changes to Issues::UpdateService' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once
described_class.new(parent, user, params).execute(issue)
end
it 'removes the label from the list it came from and adds the label of the list it goes to' do
described_class.new(parent, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, testing)
end
end
context 'when moving to closed' do
let!(:list3) { create(:list, board: board2, label: regression, position: 1) }
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) }
let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: closed.id } }
it 'delegates the close proceedings to Issues::CloseService' do
expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once
described_class.new(parent, user, params).execute(issue)
end
it 'removes all list-labels from boards and close the issue' do
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug)
expect(issue).to be_closed
end
end
context 'when moving from closed' do
let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let(:params) { { board_id: board1.id, from_list_id: closed.id, to_list_id: list2.id } }
it 'delegates the re-open proceedings to Issues::ReopenService' do
expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once
described_class.new(parent, user, params).execute(issue)
end
it 'adds the label of the list it goes to and reopen the issue' do
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, testing)
expect(issue).to be_opened
end
end
context 'when moving to same list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } }
it 'returns false' do
expect(described_class.new(parent, user, params).execute(issue)).to eq false
end
it 'keeps issues labels' do
described_class.new(parent, user, params).execute(issue)
expect(issue.reload.labels).to contain_exactly(bug, development)
end
it 'sorts issues' do
[issue, issue1, issue2].each do |issue|
issue.move_to_end && issue.save!
end
params.merge!(move_after_id: issue1.id, move_before_id: issue2.id)
described_class.new(parent, user, params).execute(issue)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
end
end
shared_examples 'lists destroy service' do
context 'when list type is label' do
it 'removes list from board' do
list = create(:list, board: board)
service = described_class.new(parent, user)
expect { service.execute(list) }.to change(board.lists, :count).by(-1)
end
it 'decrements position of higher lists' do
development = create(:list, board: board, position: 0)
review = create(:list, board: board, position: 1)
staging = create(:list, board: board, position: 2)
closed = board.closed_list
described_class.new(parent, user).execute(development)
expect(review.reload.position).to eq 0
expect(staging.reload.position).to eq 1
expect(closed.reload.position).to be_nil
end
end
it 'does not remove list from board when list type is closed' do
list = board.closed_list
service = described_class.new(parent, user)
expect { service.execute(list) }.not_to change(board.lists, :count)
end
end
shared_examples 'lists list service' do
context 'when the board has a backlog list' do
let!(:backlog_list) { create(:backlog_list, board: board) }
it 'does not create a backlog list' do
expect { service.execute(board) }.not_to change(board.lists, :count)
end
it "returns board's lists" do
expect(service.execute(board)).to eq [backlog_list, list, board.closed_list]
end
end
context 'when the board does not have a backlog list' do
it 'creates a backlog list' do
expect { service.execute(board) }.to change(board.lists, :count).by(1)
end
it "returns board's lists" do
expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
end
end
end
shared_examples 'lists move service' do
let!(:planning) { create(:list, board: board, position: 0) }
let!(:development) { create(:list, board: board, position: 1) }
let!(:review) { create(:list, board: board, position: 2) }
let!(:staging) { create(:list, board: board, position: 3) }
let!(:closed) { create(:closed_list, board: board) }
context 'when list type is set to label' do
it 'keeps position of lists when new position is nil' do
service = described_class.new(parent, user, position: nil)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to old position' do
service = described_class.new(parent, user, position: planning.position)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is negative' do
service = described_class.new(parent, user, position: -1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is equal to number of labels lists' do
service = described_class.new(parent, user, position: board.lists.label.size)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'keeps position of lists when new positon is greater than number of labels lists' do
service = described_class.new(parent, user, position: board.lists.label.size + 1)
service.execute(planning)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
it 'increments position of intermediate lists when new positon is equal to first position' do
service = described_class.new(parent, user, position: 0)
service.execute(staging)
expect(current_list_positions).to eq [1, 2, 3, 0]
end
it 'decrements position of intermediate lists when new positon is equal to last position' do
service = described_class.new(parent, user, position: board.lists.label.last.position)
service.execute(planning)
expect(current_list_positions).to eq [3, 0, 1, 2]
end
it 'decrements position of intermediate lists when new position is greater than old position' do
service = described_class.new(parent, user, position: 2)
service.execute(planning)
expect(current_list_positions).to eq [2, 0, 1, 3]
end
it 'increments position of intermediate lists when new position is lower than old position' do
service = described_class.new(parent, user, position: 1)
service.execute(staging)
expect(current_list_positions).to eq [0, 2, 3, 1]
end
end
it 'keeps position of lists when list type is closed' do
service = described_class.new(parent, user, position: 2)
service.execute(closed)
expect(current_list_positions).to eq [0, 1, 2, 3]
end
def current_list_positions
[planning, development, review, staging].map { |list| list.reload.position }
end
end
...@@ -12,7 +12,7 @@ describe 'layouts/nav/sidebar/_project' do ...@@ -12,7 +12,7 @@ describe 'layouts/nav/sidebar/_project' do
end end
describe 'issue boards' do describe 'issue boards' do
it 'has boards tab when multiple issue boards available' do it 'has board tab' do
render render
expect(rendered).to have_css('a[title="Board"]') expect(rendered).to have_css('a[title="Board"]')
......
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