Commit 21b33a7d authored by Phil Hughes's avatar Phil Hughes

Merge branch '6469-issue-board-milestone-lists' into 'master'

Resolve "Issue board milestone lists"

Closes #6469

See merge request gitlab-org/gitlab-ee!6615
parents 7463291b e3c376a2
......@@ -112,12 +112,20 @@ export default {
if (e.target) {
const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
label: ['milestone', 'assignee'],
assignee: ['milestone', 'label'],
milestone: ['label', 'assignee'],
};
if (toBoardType) {
const fromBoardType = this.list.type;
// For each list we check if the destination list is
// a the list were we should clone the issue
const shouldClone = Object.entries(cloneActions).some(entry => (
fromBoardType === entry[0] && entry[1].includes(toBoardType)));
if ((fromBoardType === 'assignee' && toBoardType === 'label') ||
(fromBoardType === 'label' && toBoardType === 'assignee')) {
if (shouldClone) {
return 'clone';
}
}
......
......@@ -50,11 +50,14 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = this.list.milestone ? this.list.milestone : null;
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees,
milestone,
project_id: this.selectedProject.id,
});
......
......@@ -27,7 +27,6 @@ class ListIssue {
this.referencePath = obj.reference_path;
this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
if (obj.project) {
......@@ -36,6 +35,7 @@ class ListIssue {
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
this.milestone_id = obj.milestone.id;
}
obj.labels.forEach((label) => {
......@@ -85,6 +85,19 @@ class ListIssue {
this.assignees = [];
}
addMilestone (milestone) {
const miletoneId = this.milestone ? this.milestone.id : null;
if (milestone.id !== miletoneId) {
this.milestone = new ListMilestone(milestone);
}
}
removeMilestone (removeMilestone) {
if (removeMilestone && removeMilestone.id === this.milestone.id) {
this.milestone = {};
}
}
getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
}
......
......@@ -4,6 +4,7 @@
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data';
import ListMilestone from './milestone';
const PER_PAGE = 20;
......@@ -49,6 +50,9 @@ class List {
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
} else if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
this.title = this.milestone.title;
}
if (!typeInfo.isBlank && this.id) {
......@@ -67,12 +71,14 @@ class List {
}
save() {
const entity = this.label || this.assignee;
const entity = this.label || this.assignee || this.milestone;
let entityType = '';
if (this.label) {
entityType = 'label_id';
} else {
} else if (this.assignee) {
entityType = 'assignee_id';
} else if (this.milestone) {
entityType = 'milestone_id';
}
return gl.boardService
......@@ -187,6 +193,13 @@ class List {
issue.addAssignee(this.assignee);
}
if (this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
}
issue.addMilestone(this.milestone);
}
if (listFrom) {
this.issuesSize += 1;
......
class ListMilestone {
export default class ListMilestone {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
this.description = obj.description;
}
}
......
......@@ -126,6 +126,16 @@ gl.issueBoards.BoardsStore = {
issue.findAssignee(listTo.assignee)) {
const targetIssue = listTo.findIssue(issue.id);
targetIssue.removeAssignee(listFrom.assignee);
} else if (listTo.type === 'milestone') {
const currentMilestone = issue.milestone;
const currentLists = this.state.lists
.filter(list => (list.type === 'milestone' && list.id !== listTo.id))
.filter(list => list.issues.some(listIssue => issue.id === listIssue.id));
issue.removeMilestone(currentMilestone);
issue.addMilestone(listTo.milestone);
currentLists.forEach(currentList => currentList.removeIssue(issue));
listTo.addIssue(issue, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex);
......@@ -143,6 +153,9 @@ gl.issueBoards.BoardsStore = {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue);
} else if (listTo.type === 'backlog' && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
listFrom.removeIssue(issue);
} else if (this.shouldRemoveIssue(listFrom, listTo)) {
listFrom.removeIssue(issue);
}
......@@ -162,7 +175,7 @@ gl.issueBoards.BoardsStore = {
},
findList (key, val, type = 'label') {
const filteredList = this.state.lists.filter((list) => {
const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
const byType = type ? (list.type === type) || (list.type === 'assignee') || (list.type === 'milestone') : true;
return list[key] === val && byType;
});
......
......@@ -6,7 +6,7 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
......@@ -29,11 +29,11 @@ class List < ActiveRecord::Base
end
def destroyable?
label?
self.class.destroyable_types.include?(list_type&.to_sym)
end
def movable?
label?
self.class.movable_types.include?(list_type&.to_sym)
end
def title
......
......@@ -6,12 +6,13 @@
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
= render_if_exists "shared/boards/components/list_milestone"
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
%span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"",
":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }}
......
......@@ -81,6 +81,7 @@ Rails.application.routes.draw do
resources :issues, module: :boards, only: [:index, :update]
resources :users, module: :boards, only: [:index]
resources :milestones, module: :boards, only: [:index]
end
# UserCallouts
......
This diff is collapsed.
import BoardsListSelector from './boards_list_selector/index';
export default function () {
const $addListEl = document.querySelector('#js-add-list');
return new BoardsListSelector({
propsData: {
listPath: $addListEl.querySelector('.js-new-board-list').dataset.listAssigneesPath,
listType: 'assignees',
},
}).$mount('.js-assignees-list');
}
......@@ -3,7 +3,7 @@ import { sprintf, __ } from '~/locale';
export default {
props: {
assignee: {
item: {
type: Object,
required: true,
},
......@@ -11,13 +11,13 @@ export default {
computed: {
avatarAltText() {
return sprintf(__("%{name}'s avatar"), {
name: this.assignee.name,
name: this.item.name,
});
},
},
methods: {
handleItemClick() {
this.$emit('onItemSelect', this.assignee);
this.$emit('onItemSelect', this.item);
},
},
};
......@@ -35,16 +35,16 @@ export default {
<div class="avatar-container s32">
<img
:alt="avatarAltText"
:src="assignee.avatar_url"
:src="item.avatar_url"
class="avatar s32 lazy"
/>
</div>
<div class="dropdown-user-details">
<div :title="assignee.name">{{ assignee.name }}</div>
<div :title="item.name">{{ item.name }}</div>
<div
:title="assignee.username"
:title="item.username"
class="dropdown-light-content"
>@{{ assignee.username }}</div>
>@{{ item.username }}</div>
</div>
</button>
</li>
......
import Vue from 'vue';
import _ from 'underscore';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import AssigneesListContainer from './assignees_list_container.vue';
import ListContainer from './list_container.vue';
export default Vue.extend({
components: {
AssigneesListContainer,
ListContainer,
},
props: {
listAssigneesPath: {
listPath: {
type: String,
required: true,
},
listType: {
type: String,
required: true,
},
......@@ -23,43 +27,68 @@ export default Vue.extend({
};
},
mounted() {
this.loadAssignees();
this.loadList();
},
methods: {
loadAssignees() {
if (this.store.state.assignees.length) {
loadList() {
if (this.store.state[this.listType].length) {
return Promise.resolve();
}
return axios
.get(this.listAssigneesPath)
.get(this.listPath)
.then(({ data }) => {
this.loading = false;
this.store.state.assignees = data;
this.store.state[this.listType] = data;
})
.catch(() => {
this.loading = false;
Flash(__('Something went wrong while fetching assignees list'));
Flash(sprintf(__('Something went wrong while fetching %{listType} list'), {
listType: this.listType,
}));
});
},
handleItemClick(assignee) {
if (!this.store.findList('title', assignee.name)) {
this.store.new({
title: assignee.name,
filterItems(term, items) {
const query = term.toLowerCase();
return items.filter((item) => {
const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
const foundName = name.indexOf(query) > -1;
if (this.listType === 'milestones') {
return foundName;
}
const username = item.username.toLowerCase();
return foundName || username.indexOf(query) > -1;
});
},
handleItemClick(item) {
if (!this.store.findList('title', item.name)) {
const list = {
title: item.name,
position: this.store.state.lists.length - 2,
list_type: 'assignee',
user: assignee,
});
list_type: this.listType,
};
if (this.listType === 'milestones') {
list.milestone = item;
} else if (this.listType === 'assignees') {
list.user = item;
}
this.store.new(list);
this.store.state.lists = _.sortBy(this.store.state.lists, 'position');
}
},
},
render(createElement) {
return createElement('assignees-list-container', {
return createElement('list-container', {
props: {
loading: this.loading,
assignees: this.store.state.assignees,
items: this.store.state[this.listType],
listType: this.listType,
},
on: {
onItemSelect: this.handleItemClick,
......
<script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AssigneesListFilter from './assignees_list_filter.vue';
import AssigneesListContent from './assignees_list_content.vue';
import ListFilter from './list_filter.vue';
import ListContent from './list_content.vue';
export default {
components: {
LoadingIcon,
AssigneesListFilter,
AssigneesListContent,
ListFilter,
ListContent,
},
props: {
loading: {
type: Boolean,
required: true,
},
assignees: {
items: {
type: Array,
required: true,
},
listType: {
type: String,
required: true,
},
},
data() {
return {
......@@ -26,18 +30,18 @@ export default {
};
},
computed: {
filteredAssignees() {
if (!this.query) {
return this.assignees;
}
filteredItems() {
if (!this.query) return this.items;
// fuzzaldrinPlus doesn't support filtering
// on multiple keys hence we're using plain JS.
const query = this.query.toLowerCase();
return this.assignees.filter((assignee) => {
const name = assignee.name.toLowerCase();
const username = assignee.username.toLowerCase();
return this.items.filter((item) => {
const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
if (this.listType === 'milestones') {
return name.indexOf(query) > -1;
}
const username = item.username.toLowerCase();
return name.indexOf(query) > -1 || username.indexOf(query) > -1;
});
},
......@@ -46,8 +50,8 @@ export default {
handleSearch(query) {
this.query = query;
},
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
handleItemClick(item) {
this.$emit('onItemSelect', item);
},
},
};
......@@ -61,12 +65,13 @@ export default {
>
<loading-icon />
</div>
<assignees-list-filter
<list-filter
@onSearchInput="handleSearch"
/>
<assignees-list-content
<list-content
v-if="!loading"
:assignees="filteredAssignees"
:items="filteredItems"
:list-type="listType"
@onItemSelect="handleItemClick"
/>
</div>
......
<script>
import AssigneesListItem from './assignees_list_item.vue';
import MilestoneListItem from './milestones_list_item.vue';
export default {
components: {
AssigneesListItem,
},
props: {
assignees: {
items: {
type: Array,
required: true,
},
listType: {
type: String,
required: true,
},
},
computed: {
listContentComponent() {
return this.listType === 'assignees' ? AssigneesListItem : MilestoneListItem;
},
},
methods: {
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
handleItemClick(item) {
this.$emit('onItemSelect', item);
},
},
};
......@@ -22,10 +29,11 @@ export default {
<template>
<div class="dropdown-content">
<ul>
<assignees-list-item
v-for="assignee in assignees"
:key="assignee.id"
:assignee="assignee"
<component
v-for="item in items"
:is="listContentComponent"
:key="item.id"
:item="item"
@onItemSelect="handleItemClick"
/>
</ul>
......
<script>
export default {
props: {
item: {
type: Object,
required: true,
},
},
methods: {
handleItemClick() {
this.$emit('onItemSelect', this.item);
},
},
};
</script>
<template>
<li
class="filter-dropdown-item"
@click="handleItemClick"
>
<button
class="btn btn-link dropdown-user"
type="button"
>
<div class="dropdown-user-details">
<div :title="item.title">{{ item.title }}</div>
</div>
</button>
</li>
</template>
......@@ -3,7 +3,8 @@ import $ from 'jquery';
import { throttle } from 'underscore';
import '~/boards/stores/boards_store';
import BoardForm from './board_form.vue';
import AssigneesList from './assignees_list';
import AssigneeList from './assignees_list_slector';
import MilestoneList from './milestone_list_selector';
(() => {
window.gl = window.gl || {};
......@@ -38,12 +39,14 @@ import AssigneesList from './assignees_list';
loading: true,
hasScrollFade: false,
hasAssigneesListMounted: false,
hasMilestoneListMounted: false,
scrollFadeInitialized: false,
boards: [],
state: Store.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
maxPosition: 0,
store: gl.issueBoards.BoardsStore,
};
},
computed: {
......@@ -84,6 +87,7 @@ import AssigneesList from './assignees_list';
created() {
this.state.currentBoard = this.currentBoard;
Store.state.assignees = [];
Store.state.milestones = [];
$('#js-add-list').on('hide.bs.dropdown', this.handleDropdownHide);
$('.js-new-board-list-tabs').on('click', this.handleDropdownTabClick);
},
......@@ -146,13 +150,14 @@ import AssigneesList from './assignees_list';
$addListEl.data('preventClose', true);
if (e.target.dataset.action === 'tab-assignees' &&
!this.hasAssigneesListMounted) {
this.assigneeList = new AssigneesList({
propsData: {
listAssigneesPath: $addListEl.find('.js-new-board-list').data('listAssigneesPath'),
},
}).$mount('.js-assignees-list');
this.assigneeList = AssigneeList();
this.hasAssigneesListMounted = true;
}
if (e.target.dataset.action === 'tab-milestones' && !this.hasMilestoneListMounted) {
this.milstoneList = MilestoneList();
this.hasMilestoneListMounted = true;
}
},
},
});
......
import BoardsListSelector from './boards_list_selector';
export default function () {
const $addListEl = document.querySelector('#js-add-list');
return new BoardsListSelector({
propsData: {
listPath: $addListEl.querySelector('.js-new-board-list').dataset.listMilestonePath,
listType: 'milestones',
},
}).$mount('.js-milestone-list');
}
/* eslint-disable no-param-reassign */
import List from '~/boards/models/list';
import ListAssignee from '~/vue_shared/models/assignee';
import ListMilestone from '~/boards/models/milestone';
const EE_TYPES = {
promotion: {
......@@ -47,7 +48,7 @@ class ListEE extends List {
}
onNewIssueResponse(issue, data) {
issue.milestone = data.milestone;
issue.milestone = data.milestone ? new ListMilestone(data.milestone) : data.milestone;
issue.assignees = Array.isArray(data.assignees)
? data.assignees.map(assignee => new ListAssignee(assignee))
: data.assignees;
......
......@@ -12,7 +12,7 @@
.dropdown .dropdown-menu.dropdown-menu-tabs {
padding-top: 0;
width: 240px;
min-width: 240px;
.dropdown-tabs-list {
display: flex;
......
module Boards
class MilestonesController < Boards::ApplicationController
def index
milestones_finder = Boards::MilestonesFinder.new(board, current_user)
render json: MilestoneSerializer.new.represent(milestones_finder.execute)
end
end
end
......@@ -5,12 +5,12 @@ module EE
override :list_creation_attrs
def list_creation_attrs
super + %i[assignee_id]
super + %i[assignee_id milestone_id]
end
override :serialization_attrs
def serialization_attrs
super.merge(user: true)
super.merge(user: true, milestone: true)
end
end
end
......
module Boards
class MilestonesFinder
def initialize(board, current_user = nil)
@board = board
@current_user = current_user
end
def execute
finder_service.execute
end
private
def finder_service
parent = @board.parent
finder_params =
if parent.is_a?(Group)
{
group_ids: parent.self_and_ancestors
}
else
{
project_ids: [parent.id],
group_ids: parent.group&.self_and_ancestors
}
end
::MilestonesFinder.new(finder_params)
end
end
end
......@@ -8,7 +8,8 @@ module EE
override :board_list_data
def board_list_data
super.merge(list_assignees_path: board_users_path(board, :json))
super.merge(list_milestone_path: board_milestones_path(board, :json),
list_assignees_path: board_users_path(board, :json))
end
override :board_data
......
......@@ -10,31 +10,34 @@ module EE
end
base.belongs_to :user
base.belongs_to :milestone
base.validates :user, presence: true, if: :assignee?
base.validates :milestone, presence: true, if: :milestone?
base.validates :user_id, uniqueness: { scope: :board_id }, if: :assignee?
base.validates :milestone_id, uniqueness: { scope: :board_id }, if: :milestone?
base.validates :list_type,
exclusion: { in: %w[assignee], message: _('Assignee boards not available with your current license') },
exclusion: { in: %w[assignee], message: _('Assignee lists not available with your current license') },
unless: -> { board&.parent&.feature_available?(:board_assignee_lists) }
base.validates :list_type,
exclusion: { in: %w[milestone], message: _('Milestone lists not available with your current license') },
unless: -> { board&.parent&.feature_available?(:board_milestone_lists) }
end
def assignee=(user)
self.user = user
end
override :destroyable?
def destroyable?
assignee? || super
end
override :movable?
def movable?
assignee? || super
end
override :title
def title
assignee? ? user.to_reference : super
case list_type
when 'assignee'
user.to_reference
when 'milestone'
milestone.title
else
super
end
end
override :as_json
......@@ -43,16 +46,20 @@ module EE
if options.key?(:user)
json[:user] = UserSerializer.new.represent(user).as_json
end
if options.key?(:milestone)
json[:milestone] = MilestoneSerializer.new.represent(milestone).as_json
end
end
end
module ClassMethods
def destroyable_types
super + [:assignee]
super + [:assignee, :milestone]
end
def movable_types
super + [:assignee]
super + [:assignee, :milestone]
end
end
end
......
......@@ -38,6 +38,7 @@ class License < ActiveRecord::Base
admin_audit_log
auditor_user
board_assignee_lists
board_milestone_lists
cross_project_pipelines
email_additional_text
db_load_balancing
......
class MilestoneSerializer < BaseSerializer
entity API::Entities::Milestone
end
......@@ -7,10 +7,12 @@ module EE
override :issue_params
def issue_params
assignee_ids = Array(list.user_id || board.assignee&.id)
milestone_id = list.milestone_id || board.milestone_id
{
label_ids: [list.label_id, *board.label_ids],
weight: board.weight,
milestone_id: board.milestone_id,
milestone_id: milestone_id,
# This can be removed when boards have multiple assignee support.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
assignee_ids: assignee_ids
......
......@@ -6,11 +6,19 @@ module EE
override :filter
def filter(issues)
issues = without_board_assignees(issues) unless list&.movable? || list&.closed?
return super unless list&.assignee?
unless list&.movable? || list&.closed?
issues = without_assignees_from_lists(issues)
issues = without_milestones_from_lists(issues)
end
with_assignee(super)
case list&.list_type
when 'assignee'
with_assignee(super)
when 'milestone'
with_milestone(super)
else
super
end
end
override :issues_label_links
......@@ -24,30 +32,49 @@ module EE
private
def all_assignee_lists
if parent.feature_available?(:board_assignee_lists)
board.lists.assignee.where.not(user_id: nil)
else
::List.none
end
end
def all_milestone_lists
if parent.feature_available?(:board_milestone_lists)
board.lists.milestone.where.not(milestone_id: nil)
else
::List.none
end
end
def without_assignees_from_lists(issues)
return issues if all_assignee_lists.empty?
issues
.where.not(id: issues.joins(:assignees).where(users: { id: all_assignee_lists.select(:user_id) }))
end
override :metadata_fields
def metadata_fields
super.merge(total_weight: 'COALESCE(SUM(weight), 0)')
end
def board_assignee_ids
@board_assignee_ids ||=
if parent.feature_available?(:board_assignee_lists)
board.lists.movable.pluck(:user_id).compact
else
[]
end
end
def without_board_assignees(issues)
return issues unless board_assignee_ids.any?
def without_milestones_from_lists(issues)
return issues if all_milestone_lists.empty?
issues.where.not(id: issues.joins(:assignees).where(users: { id: board_assignee_ids }))
issues.where("milestone_id NOT IN (?) OR milestone_id IS NULL",
all_milestone_lists.select(:milestone_id))
end
def with_assignee(issues)
issues.assigned_to(list.user)
end
def with_milestone(issues)
issues.where(milestone_id: list.milestone_id)
end
# Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone?
......
......@@ -14,7 +14,7 @@ module EE
args.delete(:remove_label_ids)
end
args.merge(assignee_ids: assignee_ids(issue))
args.merge(list_movement_args(issue))
end
def both_are_list_type?(type)
......@@ -27,6 +27,25 @@ module EE
moving_from_list.list_type == moving_to_list.list_type
end
def list_movement_args(issue)
assignee_ids = assignee_ids(issue)
milestone_id = milestone_id(issue)
{
assignee_ids: assignee_ids,
milestone_id: milestone_id
}
end
def milestone_id(issue)
# We want to nullify the issue milestone.
return if moving_to_list.backlog?
# Moving to a list which is not a 'milestone list' will keep
# the already existent milestone.
[issue.milestone_id, moving_to_list.milestone_id].compact.last
end
def assignee_ids(issue)
assignees = (issue.assignee_ids + [moving_to_list.user_id]).compact
......
......@@ -6,9 +6,15 @@ module EE
override :type
def type
return :assignee if params.keys.include?('assignee_id')
super
# We don't ever expect to have more than one list
# type param at once.
if params.key?('assignee_id')
:assignee
elsif params.key?('milestone_id')
:milestone
else
super
end
end
override :target
......@@ -17,17 +23,28 @@ module EE
case type
when :assignee
find_user(board)
when :milestone
find_milestone(board)
else
super
end
end
end
def find_milestone(board)
milestones = milestone_finder(board).execute
milestones.find(params['milestone_id'])
end
def find_user(board)
user_ids = user_finder(board).execute.select(:user_id)
::User.where(id: user_ids).find(params['assignee_id'])
end
def milestone_finder(board)
@milestone_finder ||= ::Boards::MilestonesFinder.new(board, current_user)
end
def user_finder(board)
@user_finder ||= ::Boards::UsersFinder.new(board, current_user)
end
......
......@@ -2,13 +2,35 @@ module EE
module Boards
module Lists
module ListService
# When adding a new licensed type, make sure to also add
# it on license.rb with the pattern "board_<list_type>_lists"
LICENSED_LIST_TYPES = [:assignee, :milestone].freeze
extend ::Gitlab::Utils::Override
override :execute
def execute(board)
return super if board.parent.feature_available?(:board_assignee_lists)
not_available_lists =
list_type_features_availability(board).select { |_, available| !available }
if not_available_lists.any?
super.where.not(list_type: not_available_lists.keys)
else
super
end
end
private
def list_type_features_availability(board)
parent = board.parent
super.where.not(list_type: ::List.list_types[:assignee])
{}.tap do |hash|
LICENSED_LIST_TYPES.each do |list_type|
list_type_key = ::List.list_types[list_type]
hash[list_type_key] = parent&.feature_available?(:"board_#{list_type}_lists")
end
end
end
end
end
......
= content_tag(:span, sprite_icon('timer', size: 16), { "v-if": "list.milestone", "aria-hidden": "true", "class": "append-right-5" })
- if board.parent.feature_available?(:board_assignee_lists)
- assignee_lists_available = board.parent.feature_available?(:board_assignee_lists)
- milestone_lists_available = board.parent.feature_available?(:board_milestone_lists)
- if assignee_lists_available || milestone_lists_available
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.d-flex.js-new-board-list{ type: "button", data: board_list_data }
%span Add list
......@@ -7,17 +10,30 @@
%ul.nav.nav-tabs.dropdown-tabs-list.js-new-board-list-tabs{ role: 'tablist' }
%li.nav-item.dropdown-tab-item.js-tab-button-labels
%a.active{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-labels', target: '#tab-labels' } }
Label list
%li.nav-item.dropdown-tab-item.js-tab-button-assignees
%a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-assignees', target: '#tab-assignees' } }
Assignee list
Label
- if assignee_lists_available
%li.nav-item.dropdown-tab-item.js-tab-button-assignees
%a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-assignees', target: '#tab-assignees' } }
Assignee
- if milestone_lists_available
%li.nav-item.dropdown-tab-item.js-tab-button-milestones
%a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-milestones', target: '#tab-milestones' } }
Milestone
.tab-content
#tab-labels.tab-pane.tab-pane-labels.active.js-tab-container-labels{ role: 'tabpanel' }
= render partial: "shared/issuable/label_page_default", locals: { show_title: false, show_footer: true, show_create: true, show_boards_content: true, content_title: _('Label lists show all issues with the selected label.') }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create", locals: { show_close: false }
#tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' }
= render partial: "shared/issuable/assignee_page_default"
- if assignee_lists_available
#tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' }
= render partial: "shared/issuable/assignee_page_default"
- if milestone_lists_available
#tab-milestones.tab-pane.tab-pane-milestones.js-tab-container-milestones{ role: 'tabpanel' }
= render partial: "shared/issuable/milestone_page_default"
= dropdown_loading
- else
= render_ce 'shared/issuable/board_create_list_dropdown', board: board
.dropdown-page-one
.issue-board-dropdown-content
%p
= _('Milestone lists show all issues from the selected milestone.')
.js-milestone-list
---
title: Add support for milestones lists on the issue boards
merge_request: 6615
author:
type: added
---
title: Allow creating assignee lists via API
merge_request:
author:
type: added
class AddMilestoneToLists < ActiveRecord::Migration
DOWNTIME = false
def change
add_reference :lists, :milestone, index: true, foreign_key: { on_delete: :cascade }
end
end
......@@ -34,6 +34,41 @@ module EE
end
end
def create_list_params
params.slice(:label_id, :milestone_id, :assignee_id)
end
# Overrides API::BoardsResponses authorize_list_type_resource!
def authorize_list_type_resource!
if params[:label_id] && !available_labels_for(board_parent).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
if milestone_id = params[:milestone_id]
milestones = ::Boards::MilestonesFinder.new(board, current_user).execute
unless milestones.find_by(id: milestone_id)
render_api_error!({ error: 'Milestone not found!' }, 400)
end
end
if assignee_id = params[:assignee_id]
users = ::Boards::UsersFinder.new(board, current_user).execute
unless users.find_by(user_id: assignee_id)
render_api_error!({ error: 'User not found!' }, 400)
end
end
end
# Overrides API::BoardsResponses list_creation_params
params :list_creation_params do
optional :label_id, type: Integer, desc: 'The ID of an existing label'
optional :milestone_id, type: Integer, desc: 'The ID of an existing milestone'
optional :assignee_id, type: Integer, desc: 'The ID of an assignee'
exactly_one_of :label_id, :milestone_id, :assignee_id
end
params :update_params do
optional :name, type: String, desc: 'The board name'
optional :assignee_id, type: Integer, desc: 'The ID of a user to associate with board'
......
......@@ -100,6 +100,15 @@ module EE
end
end
module List
extend ActiveSupport::Concern
prepended do
expose :milestone, using: ::API::Entities::Milestone, if: -> (entity, _) { entity.milestone? }
expose :user, as: :assignee, using: ::API::Entities::UserSafe, if: -> (entity, _) { entity.assignee? }
end
end
module ApplicationSetting
extend ActiveSupport::Concern
......
require 'spec_helper'
describe Boards::MilestonesController do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
before do
create(:milestone, project: project)
project.add_maintainer(user)
sign_in(user)
end
describe 'GET index' do
it 'returns a list of all milestones of board parent' do
get :index, board_id: board.to_param, format: :json
parsed_response = JSON.parse(response.body)
expect(response).to have_gitlab_http_status(200)
expect(response.content_type).to eq('application/json')
expect(parsed_response).to all(match_schema('entities/milestone', dir: 'ee'))
expect(parsed_response.size).to eq(1)
end
end
end
......@@ -4,4 +4,11 @@ FactoryBot.define do
label nil
user
end
factory :milestone_list, parent: :list do
list_type :milestone
label nil
user nil
milestone
end
end
require 'spec_helper'
describe Boards::MilestonesFinder do
describe '#execute' do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
let(:deep_nested_group) { create(:group, parent: group) }
let(:group_project) { create(:project, group: group) }
let(:nested_group_project) { create(:project, group: nested_group) }
let!(:group_milestone) { create(:milestone, group: group, project: nil) }
let!(:group_project_milestone) { create(:milestone, project: group_project, group: nil) }
let!(:nested_group_project_milestone) { create(:milestone, project: nested_group_project, group: nil) }
let!(:nested_group_milestone) { create(:milestone, group: nested_group, project: nil) }
let!(:deep_nested_group_milestone) { create(:milestone, group: deep_nested_group, project: nil) }
let(:user) { create(:user) }
let(:finder) { described_class.new(board, user) }
context 'when project board', :nested_groups do
let(:board) { create(:board, project: nested_group_project, group: nil) }
it 'returns milestones from board project and ancestors groups' do
group.add_developer(user)
results = finder.execute
expect(results).to contain_exactly(nested_group_project_milestone,
nested_group_milestone,
group_milestone)
end
end
context 'when group board', :nested_groups do
let(:board) { create(:board, project: nil, group: nested_group) }
it 'returns milestones from board group and its ancestors' do
group.add_developer(user)
results = finder.execute
expect(results).to contain_exactly(group_milestone,
nested_group_milestone)
end
end
end
end
{
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
"created_at": { "type": "string" },
"updated_at": { "type": "string" },
"start_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"web_url": { "type": "string" }
},
"additionalProperties": false
}
......@@ -48,4 +48,4 @@
}
}
]
}
\ No newline at end of file
}
import Vue from 'vue';
import AssigneesListItemComponent from 'ee/boards/components/assignees_list/assignees_list_item.vue';
import AssigneesListItemComponent from 'ee/boards/components/boards_list_selector/assignees_list_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
......@@ -9,7 +9,7 @@ const createComponent = () => {
const Component = Vue.extend(AssigneesListItemComponent);
return mountComponent(Component, {
assignee: mockAssigneesList[0],
item: mockAssigneesList[0],
});
};
......
......@@ -2,19 +2,20 @@ import '~/boards/stores/boards_store';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import AssigneesListComponent from 'ee/boards/components/assignees_list/';
import BoardListSelectorComponent from 'ee/boards/components/boards_list_selector/';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
import { TEST_HOST } from 'spec/test_constants';
describe('AssigneesListComponent', () => {
describe('BoardListSelectorComponent', () => {
const dummyEndpoint = `${TEST_HOST}/users.json`;
const createComponent = () =>
mountComponent(AssigneesListComponent, {
listAssigneesPath: dummyEndpoint,
mountComponent(BoardListSelectorComponent, {
listPath: dummyEndpoint,
listType: 'assignees',
});
let vm;
......@@ -43,13 +44,13 @@ describe('AssigneesListComponent', () => {
});
describe('methods', () => {
describe('loadAssignees', () => {
describe('loadList', () => {
it('calls axios.get and sets response to store.state.assignees', done => {
mock.onGet(dummyEndpoint).reply(200, mockAssigneesList);
gl.issueBoards.BoardsStore.state.assignees = [];
vm
.loadAssignees()
.loadList()
.then(() => {
expect(vm.loading).toBe(false);
expect(vm.store.state.assignees.length).toBe(mockAssigneesList.length);
......@@ -63,7 +64,7 @@ describe('AssigneesListComponent', () => {
gl.issueBoards.BoardsStore.state.assignees = mockAssigneesList;
vm
.loadAssignees()
.loadList()
.then(() => {
expect(axios.get).not.toHaveBeenCalled();
})
......@@ -76,7 +77,7 @@ describe('AssigneesListComponent', () => {
gl.issueBoards.BoardsStore.state.assignees = [];
vm
.loadAssignees()
.loadList()
.then(() => {
expect(vm.loading).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe(
......
import Vue from 'vue';
import AssigneesListContainerComponent from 'ee/boards/components/assignees_list/assignees_list_container.vue';
import ListContainerComponent from 'ee/boards/components/boards_list_selector/list_container.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = () => {
const Component = Vue.extend(AssigneesListContainerComponent);
const Component = Vue.extend(ListContainerComponent);
return mountComponent(Component, {
loading: false,
assignees: mockAssigneesList,
items: mockAssigneesList,
listType: 'assignees',
});
};
describe('AssigneesListContainerComponent', () => {
describe('ListContainerComponent', () => {
let vm;
beforeEach(() => {
......@@ -26,26 +27,26 @@ describe('AssigneesListContainerComponent', () => {
});
describe('computed', () => {
describe('filteredAssignees', () => {
describe('filteredItems', () => {
it('returns assignees list as it is when `query` is empty', () => {
vm.query = '';
expect(vm.filteredAssignees.length).toBe(mockAssigneesList.length);
expect(vm.filteredItems.length).toBe(mockAssigneesList.length);
});
it('returns filtered assignees list as it is when `query` has name', () => {
const assignee = mockAssigneesList[0];
vm.query = assignee.name;
expect(vm.filteredAssignees.length).toBe(1);
expect(vm.filteredAssignees[0].name).toBe(assignee.name);
expect(vm.filteredItems.length).toBe(1);
expect(vm.filteredItems[0].name).toBe(assignee.name);
});
it('returns filtered assignees list as it is when `query` has username', () => {
const assignee = mockAssigneesList[0];
vm.query = assignee.username;
expect(vm.filteredAssignees.length).toBe(1);
expect(vm.filteredAssignees[0].username).toBe(assignee.username);
expect(vm.filteredItems.length).toBe(1);
expect(vm.filteredItems[0].username).toBe(assignee.username);
});
});
});
......
import Vue from 'vue';
import AssigneesListContentComponent from 'ee/boards/components/assignees_list/assignees_list_content.vue';
import ListContentComponent from 'ee/boards/components/boards_list_selector/list_content.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = () => {
const Component = Vue.extend(AssigneesListContentComponent);
const Component = Vue.extend(ListContentComponent);
return mountComponent(Component, {
assignees: mockAssigneesList,
items: mockAssigneesList,
listType: 'assignees',
});
};
describe('AssigneesListContentComponent', () => {
describe('ListContentComponent', () => {
let vm;
beforeEach(() => {
......
import Vue from 'vue';
import AssigneesListFilterComponent from 'ee/boards/components/assignees_list/assignees_list_filter.vue';
import ListFilterComponent from 'ee/boards/components/boards_list_selector/list_filter.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(AssigneesListFilterComponent);
const Component = Vue.extend(ListFilterComponent);
return mountComponent(Component);
};
describe('AssigneesListFilterComponent', () => {
describe('ListFilterComponent', () => {
let vm;
beforeEach(() => {
......
require 'rails_helper'
describe List do
context 'when it is an assignee type' do
let(:board) { create(:board) }
let(:board) { create(:board) }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:milestone) }
end
context 'when it is an assignee type' do
subject { described_class.new(list_type: :assignee, board: board) }
it { is_expected.to be_destroyable }
it { is_expected.to be_movable }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:user) }
end
......@@ -25,4 +26,30 @@ describe List do
end
end
end
context 'when it is a milestone type' do
let(:milestone) { build(:milestone, title: 'awesome-release') }
subject { described_class.new(list_type: :milestone, milestone: milestone, board: board) }
it { is_expected.to be_destroyable }
it { is_expected.to be_movable }
describe 'validations' do
it { is_expected.to validate_presence_of(:milestone) }
it 'is invalid when feature is not available' do
stub_licensed_features(board_milestone_lists: false)
expect(subject).to be_invalid
expect(subject.errors[:list_type])
.to contain_exactly('Milestone lists not available with your current license')
end
end
describe '#title' do
it 'returns the milestone title' do
expect(subject.title).to eq('awesome-release')
end
end
end
end
......@@ -7,4 +7,11 @@ describe API::Boards do
set(:board) { create(:board, project: board_parent, milestone: milestone) }
it_behaves_like 'multiple and scoped issue boards', "/projects/:id/boards"
describe 'POST /projects/:id/boards/:board_id/lists' do
let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}/lists" }
it_behaves_like 'milestone board list'
it_behaves_like 'assignee board list'
end
end
......@@ -46,4 +46,11 @@ describe API::GroupBoards do
it_behaves_like 'group and project boards', "/groups/:id/boards", true
it_behaves_like 'multiple and scoped issue boards', "/groups/:id/boards"
describe 'POST /groups/:id/boards/:board_id/lists' do
let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
it_behaves_like 'milestone board list'
it_behaves_like 'assignee board list'
end
end
......@@ -6,19 +6,46 @@ describe Boards::Issues::CreateService do
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') }
let(:list) { create(:list, board: board, user: user, list_type: List.list_types[:assignee], position: 0) }
subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
subject(:service) do
described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue')
end
before do
stub_licensed_features(board_assignee_lists: true)
project.add_developer(user)
end
it 'assigns the issue to the List assignee' do
issue = service.execute
context 'assignees list' do
before do
stub_licensed_features(board_assignee_lists: true)
end
let(:list) do
create(:list, board: board, user: user, list_type: List.list_types[:assignee], position: 0)
end
it 'assigns the issue to the List assignee' do
issue = service.execute
expect(issue.assignees).to eq([user])
end
end
context 'milestone list' do
before do
stub_licensed_features(board_milestone_lists: true)
end
let(:milestone) { create(:milestone, project: project) }
let(:list) do
create(:list, board: board, milestone: milestone, list_type: List.list_types[:milestone], position: 0)
end
it 'assigns the issue to the list milestone' do
issue = service.execute
expect(issue.assignees).to eq([user])
expect(issue.milestone).to eq(milestone)
end
end
end
end
......@@ -20,6 +20,7 @@ describe Boards::Issues::ListService, services: true do
let(:p3) { create(:group_label, title: 'P3', group: group) }
let(:user_list) { create(:user_list, board: board, position: 2) }
let(:milestone_list) { create(:milestone_list, board: board, position: 3) }
let(:backlog) { create(:backlog_list, board: board) }
let(:list1) { create(:list, board: board, label: development, position: 0) }
let(:list2) { create(:list, board: board, label: testing, position: 1) }
......@@ -44,11 +45,36 @@ describe Boards::Issues::ListService, services: true do
let(:parent) { group }
before do
stub_licensed_features(board_assignee_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
parent.add_developer(user)
opened_issue3.assignees.push(user_list.user)
end
context 'milestone lists' do
let!(:milestone_issue) { create(:labeled_issue, project: project, milestone: milestone_list.milestone, labels: [p3]) }
it 'returns issues from milestone persisted in the list' do
params = { board_id: board.id, id: milestone_list.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to contain_exactly(milestone_issue)
end
context 'backlog list context' do
it 'returns issues without milestones and without milestones from other lists' do
params = { board_id: board.id, id: backlog.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to contain_exactly(opened_issue1, # milestone from this issue is not in a list
opened_issue2, # milestone from this issue is not in a list
reopened_issue1) # has no milestone
end
end
end
context '#metadata' do
it 'returns issues count and weight for list' do
params = { board_id: board.id, id: backlog.id }
......@@ -72,7 +98,7 @@ describe Boards::Issues::ListService, services: true do
end
context 'when list_id is missing' do
context 'when board does not have a milestone' do
context 'when board is not scoped by milestone' do
it 'returns opened issues without board labels and assignees applied' do
params = { board_id: board.id }
......@@ -82,14 +108,15 @@ describe Boards::Issues::ListService, services: true do
end
end
context 'when board have a milestone' do
context 'when board is scoped by milestone' do
it 'returns opened issues without board labels, assignees, or milestone applied' do
params = { board_id: board.id }
board.update_attribute(:milestone, m1)
issues = described_class.new(parent, user, params).execute
expect(issues).to match_array([opened_issue2, list1_issue2, reopened_issue1, opened_issue1])
expect(issues)
.to match_array([opened_issue2, list1_issue2, reopened_issue1, opened_issue1])
end
context 'when milestone is predefined' do
......
......@@ -2,27 +2,46 @@ require 'spec_helper'
describe Boards::Lists::CreateService do
describe '#execute' do
let(:parent) { create(:project) }
let(:board) { create(:board, project: parent) }
let(:label) { create(:label, project: parent, name: 'in-progress') }
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:other_user) { create(:user) }
context 'when assignee_id param is sent' do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
subject(:service) { described_class.new(project, user, 'assignee_id' => other_user.id) }
subject(:service) { described_class.new(parent, user, 'assignee_id' => other_user.id) }
before do
project.add_developer(user)
project.add_developer(other_user)
before do
parent.add_developer(user)
parent.add_developer(other_user)
stub_licensed_features(board_assignee_lists: true)
end
stub_licensed_features(board_assignee_lists: true)
it 'creates a new assignee list' do
list = service.execute(board)
expect(list.list_type).to eq('assignee')
expect(list).to be_valid
end
end
it 'creates a new assignee list' do
list = service.execute(board)
context 'when milestone_id param is sent' do
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
subject(:service) { described_class.new(project, user, 'milestone_id' => milestone.id) }
before do
project.add_developer(user)
stub_licensed_features(board_milestone_lists: true)
end
it 'creates a milestone list when param is valid' do
list = service.execute(board)
expect(list.list_type).to eq('assignee')
expect(list).to be_valid
expect(list.list_type).to eq('milestone')
expect(list).to be_valid
end
end
end
end
......@@ -10,6 +10,7 @@ describe Boards::Lists::ListService do
context 'when the feature is enabled' do
before do
allow(board.parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(true)
allow(board.parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(false)
end
it 'returns all lists' do
......@@ -24,6 +25,30 @@ describe Boards::Lists::ListService do
end
end
shared_examples 'list service for board with milestone lists' do
let!(:milestone_list) { build(:milestone_list, board: board).tap { |l| l.save(validate: false) } }
let!(:backlog_list) { create(:backlog_list, board: board) }
let!(:list) { create(:list, board: board, label: label) }
context 'when the feature is enabled' do
before do
allow(board.parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(false)
allow(board.parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(true)
end
it 'returns all lists' do
expect(service.execute(board))
.to match_array([backlog_list, list, milestone_list, board.closed_list])
end
end
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
end
end
end
context 'when board parent is a project' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
......@@ -31,6 +56,7 @@ describe Boards::Lists::ListService do
let(:service) { described_class.new(project, double) }
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
end
context 'when board parent is a group' do
......@@ -40,6 +66,7 @@ describe Boards::Lists::ListService do
let(:service) { described_class.new(group, double) }
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
end
end
end
# frozen_string_literal: true
shared_examples_for 'assignee board list' do
context 'when assignee_id is sent' do
it 'returns 400 if user is not found' do
other_user = create(:user)
post api(url, user), assignee_id: other_user.id
expect(response).to have_gitlab_http_status(400)
expect(json_response.dig('message', 'error')).to eq('User not found!')
end
it 'returns 400 if assignee list feature is not available' do
stub_licensed_features(board_assignee_lists: false)
post api(url, user), assignee_id: user.id
expect(response).to have_gitlab_http_status(400)
expect(json_response.dig('message', 'list_type'))
.to contain_exactly('Assignee lists not available with your current license')
end
it 'creates an assignee list if user is found' do
stub_licensed_features(board_assignee_lists: true)
post api(url, user), assignee_id: user.id
expect(response).to have_gitlab_http_status(201)
expect(json_response.dig('assignee', 'id')).to eq(user.id)
end
end
end
shared_examples_for 'milestone board list' do
context 'when milestone_id is sent' do
it 'returns 400 if milestone is not found' do
other_milestone = create(:milestone)
post api(url, user), milestone_id: other_milestone.id
expect(response).to have_gitlab_http_status(400)
expect(json_response.dig('message', 'error')).to eq('Milestone not found!')
end
it 'returns 400 if milestone list feature is not available' do
stub_licensed_features(board_milestone_lists: false)
post api(url, user), milestone_id: milestone.id
expect(response).to have_gitlab_http_status(400)
expect(json_response.dig('message', 'list_type'))
.to contain_exactly('Milestone lists not available with your current license')
end
it 'creates a milestone list if milestone is found' do
stub_licensed_features(board_milestone_lists: true)
post api(url, user), milestone_id: milestone.id
expect(response).to have_gitlab_http_status(201)
expect(json_response.dig('milestone', 'id')).to eq(milestone.id)
end
end
end
module API
class Boards < Grape::API
include BoardsResponses
include EE::API::BoardsResponses
include PaginationParams
before { authenticate! }
......@@ -71,12 +72,10 @@ module API
success Entities::List
end
params do
requires :label_id, type: Integer, desc: 'The ID of an existing label'
use :list_creation_params
end
post '/lists' do
unless available_labels_for(user_project).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize_list_type_resource!
authorize!(:admin_list, user_project)
......
......@@ -14,7 +14,7 @@ module API
def create_list
create_list_service =
::Boards::Lists::CreateService.new(board_parent, current_user, { label_id: params[:label_id] })
::Boards::Lists::CreateService.new(board_parent, current_user, create_list_params)
list = create_list_service.execute(board)
......@@ -25,6 +25,10 @@ module API
end
end
def create_list_params
params.slice(:label_id)
end
def move_list(list)
move_list_service =
::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i })
......@@ -44,6 +48,16 @@ module API
end
end
end
def authorize_list_type_resource!
unless available_labels_for(board_parent).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
end
params :list_creation_params do
requires :label_id, type: Integer, desc: 'The ID of an existing label'
end
end
end
end
......
......@@ -1453,6 +1453,7 @@ API::Entities.prepend_entity(::API::Entities::Board, with: EE::API::Entities::Bo
API::Entities.prepend_entity(::API::Entities::Group, with: EE::API::Entities::Group)
API::Entities.prepend_entity(::API::Entities::GroupDetail, with: EE::API::Entities::GroupDetail)
API::Entities.prepend_entity(::API::Entities::IssueBasic, with: EE::API::Entities::IssueBasic)
API::Entities.prepend_entity(::API::Entities::List, with: EE::API::Entities::List)
API::Entities.prepend_entity(::API::Entities::MergeRequestBasic, with: EE::API::Entities::MergeRequestBasic)
API::Entities.prepend_entity(::API::Entities::Namespace, with: EE::API::Entities::Namespace)
API::Entities.prepend_entity(::API::Entities::Project, with: EE::API::Entities::Project)
......
......@@ -71,12 +71,10 @@ module API
success Entities::List
end
params do
requires :label_id, type: Integer, desc: 'The ID of an existing label'
use :list_creation_params
end
post '/lists' do
unless available_labels_for(board_parent).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize_list_type_resource!
authorize!(:admin_list, user_group)
......
......@@ -723,7 +723,7 @@ msgstr ""
msgid "Assignee"
msgstr ""
msgid "Assignee boards not available with your current license"
msgid "Assignee lists not available with your current license"
msgstr ""
msgid "Assignee lists show all issues assigned to the selected user."
......@@ -4357,6 +4357,12 @@ msgstr ""
msgid "Milestone"
msgstr ""
msgid "Milestone lists not available with your current license"
msgstr ""
msgid "Milestone lists show all issues from the selected milestone."
msgstr ""
msgid "Milestones"
msgstr ""
......@@ -6046,7 +6052,7 @@ msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
msgid "Something went wrong while fetching assignees list"
msgid "Something went wrong while fetching %{listType} list"
msgstr ""
msgid "Something went wrong while fetching group member contributions"
......
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