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 { ...@@ -112,12 +112,20 @@ export default {
if (e.target) { if (e.target) {
const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType; const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
label: ['milestone', 'assignee'],
assignee: ['milestone', 'label'],
milestone: ['label', 'assignee'],
};
if (toBoardType) { if (toBoardType) {
const fromBoardType = this.list.type; 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') || if (shouldClone) {
(fromBoardType === 'label' && toBoardType === 'assignee')) {
return 'clone'; return 'clone';
} }
} }
......
...@@ -50,11 +50,14 @@ export default { ...@@ -50,11 +50,14 @@ export default {
const labels = this.list.label ? [this.list.label] : []; const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : []; const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = this.list.milestone ? this.list.milestone : null;
const issue = new ListIssue({ const issue = new ListIssue({
title: this.title, title: this.title,
labels, labels,
subscribed: true, subscribed: true,
assignees, assignees,
milestone,
project_id: this.selectedProject.id, project_id: this.selectedProject.id,
}); });
......
...@@ -27,7 +27,6 @@ class ListIssue { ...@@ -27,7 +27,6 @@ class ListIssue {
this.referencePath = obj.reference_path; this.referencePath = obj.reference_path;
this.path = obj.real_path; this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id; this.project_id = obj.project_id;
if (obj.project) { if (obj.project) {
...@@ -36,6 +35,7 @@ class ListIssue { ...@@ -36,6 +35,7 @@ class ListIssue {
if (obj.milestone) { if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
this.milestone_id = obj.milestone.id;
} }
obj.labels.forEach((label) => { obj.labels.forEach((label) => {
...@@ -85,6 +85,19 @@ class ListIssue { ...@@ -85,6 +85,19 @@ class ListIssue {
this.assignees = []; 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 () { getLists () {
return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id));
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import ListLabel from '~/vue_shared/models/label'; import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee'; import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data'; import queryData from '../utils/query_data';
import ListMilestone from './milestone';
const PER_PAGE = 20; const PER_PAGE = 20;
...@@ -49,6 +50,9 @@ class List { ...@@ -49,6 +50,9 @@ class List {
} else if (obj.user) { } else if (obj.user) {
this.assignee = new ListAssignee(obj.user); this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name; 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) { if (!typeInfo.isBlank && this.id) {
...@@ -67,12 +71,14 @@ class List { ...@@ -67,12 +71,14 @@ class List {
} }
save() { save() {
const entity = this.label || this.assignee; const entity = this.label || this.assignee || this.milestone;
let entityType = ''; let entityType = '';
if (this.label) { if (this.label) {
entityType = 'label_id'; entityType = 'label_id';
} else { } else if (this.assignee) {
entityType = 'assignee_id'; entityType = 'assignee_id';
} else if (this.milestone) {
entityType = 'milestone_id';
} }
return gl.boardService return gl.boardService
...@@ -187,6 +193,13 @@ class List { ...@@ -187,6 +193,13 @@ class List {
issue.addAssignee(this.assignee); issue.addAssignee(this.assignee);
} }
if (this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
}
issue.addMilestone(this.milestone);
}
if (listFrom) { if (listFrom) {
this.issuesSize += 1; this.issuesSize += 1;
......
class ListMilestone { export default class ListMilestone {
constructor(obj) { constructor(obj) {
this.id = obj.id; this.id = obj.id;
this.title = obj.title; 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 = { ...@@ -126,6 +126,16 @@ gl.issueBoards.BoardsStore = {
issue.findAssignee(listTo.assignee)) { issue.findAssignee(listTo.assignee)) {
const targetIssue = listTo.findIssue(issue.id); const targetIssue = listTo.findIssue(issue.id);
targetIssue.removeAssignee(listFrom.assignee); 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 { } else {
// Add to new lists issues if it doesn't already exist // Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex); listTo.addIssue(issue, listFrom, newIndex);
...@@ -143,6 +153,9 @@ gl.issueBoards.BoardsStore = { ...@@ -143,6 +153,9 @@ gl.issueBoards.BoardsStore = {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') { } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee); issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} else if (listTo.type === 'backlog' && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
listFrom.removeIssue(issue);
} else if (this.shouldRemoveIssue(listFrom, listTo)) { } else if (this.shouldRemoveIssue(listFrom, listTo)) {
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} }
...@@ -162,7 +175,7 @@ gl.issueBoards.BoardsStore = { ...@@ -162,7 +175,7 @@ gl.issueBoards.BoardsStore = {
}, },
findList (key, val, type = 'label') { findList (key, val, type = 'label') {
const filteredList = this.state.lists.filter((list) => { 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; return list[key] === val && byType;
}); });
......
...@@ -6,7 +6,7 @@ class List < ActiveRecord::Base ...@@ -6,7 +6,7 @@ class List < ActiveRecord::Base
belongs_to :board belongs_to :board
belongs_to :label 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 :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label? validates :label, :position, presence: true, if: :label?
...@@ -29,11 +29,11 @@ class List < ActiveRecord::Base ...@@ -29,11 +29,11 @@ class List < ActiveRecord::Base
end end
def destroyable? def destroyable?
label? self.class.destroyable_types.include?(list_type&.to_sym)
end end
def movable? def movable?
label? self.class.movable_types.include?(list_type&.to_sym)
end end
def title def title
......
...@@ -6,12 +6,13 @@ ...@@ -6,12 +6,13 @@
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" } "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" } %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText -# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } %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" } } ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }} {{ list.title }}
......
...@@ -81,6 +81,7 @@ Rails.application.routes.draw do ...@@ -81,6 +81,7 @@ Rails.application.routes.draw do
resources :issues, module: :boards, only: [:index, :update] resources :issues, module: :boards, only: [:index, :update]
resources :users, module: :boards, only: [:index] resources :users, module: :boards, only: [:index]
resources :milestones, module: :boards, only: [:index]
end end
# UserCallouts # 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'; ...@@ -3,7 +3,7 @@ import { sprintf, __ } from '~/locale';
export default { export default {
props: { props: {
assignee: { item: {
type: Object, type: Object,
required: true, required: true,
}, },
...@@ -11,13 +11,13 @@ export default { ...@@ -11,13 +11,13 @@ export default {
computed: { computed: {
avatarAltText() { avatarAltText() {
return sprintf(__("%{name}'s avatar"), { return sprintf(__("%{name}'s avatar"), {
name: this.assignee.name, name: this.item.name,
}); });
}, },
}, },
methods: { methods: {
handleItemClick() { handleItemClick() {
this.$emit('onItemSelect', this.assignee); this.$emit('onItemSelect', this.item);
}, },
}, },
}; };
...@@ -35,16 +35,16 @@ export default { ...@@ -35,16 +35,16 @@ export default {
<div class="avatar-container s32"> <div class="avatar-container s32">
<img <img
:alt="avatarAltText" :alt="avatarAltText"
:src="assignee.avatar_url" :src="item.avatar_url"
class="avatar s32 lazy" class="avatar s32 lazy"
/> />
</div> </div>
<div class="dropdown-user-details"> <div class="dropdown-user-details">
<div :title="assignee.name">{{ assignee.name }}</div> <div :title="item.name">{{ item.name }}</div>
<div <div
:title="assignee.username" :title="item.username"
class="dropdown-light-content" class="dropdown-light-content"
>@{{ assignee.username }}</div> >@{{ item.username }}</div>
</div> </div>
</button> </button>
</li> </li>
......
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import Flash from '~/flash'; import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import AssigneesListContainer from './assignees_list_container.vue'; import ListContainer from './list_container.vue';
export default Vue.extend({ export default Vue.extend({
components: { components: {
AssigneesListContainer, ListContainer,
}, },
props: { props: {
listAssigneesPath: { listPath: {
type: String,
required: true,
},
listType: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -23,43 +27,68 @@ export default Vue.extend({ ...@@ -23,43 +27,68 @@ export default Vue.extend({
}; };
}, },
mounted() { mounted() {
this.loadAssignees(); this.loadList();
}, },
methods: { methods: {
loadAssignees() { loadList() {
if (this.store.state.assignees.length) { if (this.store.state[this.listType].length) {
return Promise.resolve(); return Promise.resolve();
} }
return axios return axios
.get(this.listAssigneesPath) .get(this.listPath)
.then(({ data }) => { .then(({ data }) => {
this.loading = false; this.loading = false;
this.store.state.assignees = data; this.store.state[this.listType] = data;
}) })
.catch(() => { .catch(() => {
this.loading = false; 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) { filterItems(term, items) {
if (!this.store.findList('title', assignee.name)) { const query = term.toLowerCase();
this.store.new({
title: assignee.name, return items.filter((item) => {
position: this.store.state.lists.length - 2, const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
list_type: 'assignee', const foundName = name.indexOf(query) > -1;
user: assignee,
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: 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'); this.store.state.lists = _.sortBy(this.store.state.lists, 'position');
} }
}, },
}, },
render(createElement) { render(createElement) {
return createElement('assignees-list-container', { return createElement('list-container', {
props: { props: {
loading: this.loading, loading: this.loading,
assignees: this.store.state.assignees, items: this.store.state[this.listType],
listType: this.listType,
}, },
on: { on: {
onItemSelect: this.handleItemClick, onItemSelect: this.handleItemClick,
......
<script> <script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AssigneesListFilter from './assignees_list_filter.vue'; import ListFilter from './list_filter.vue';
import AssigneesListContent from './assignees_list_content.vue'; import ListContent from './list_content.vue';
export default { export default {
components: { components: {
LoadingIcon, LoadingIcon,
AssigneesListFilter, ListFilter,
AssigneesListContent, ListContent,
}, },
props: { props: {
loading: { loading: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
assignees: { items: {
type: Array, type: Array,
required: true, required: true,
}, },
listType: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -26,18 +30,18 @@ export default { ...@@ -26,18 +30,18 @@ export default {
}; };
}, },
computed: { computed: {
filteredAssignees() { filteredItems() {
if (!this.query) { if (!this.query) return this.items;
return this.assignees;
}
// fuzzaldrinPlus doesn't support filtering
// on multiple keys hence we're using plain JS.
const query = this.query.toLowerCase(); const query = this.query.toLowerCase();
return this.assignees.filter((assignee) => { return this.items.filter((item) => {
const name = assignee.name.toLowerCase(); const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
const username = assignee.username.toLowerCase();
if (this.listType === 'milestones') {
return name.indexOf(query) > -1;
}
const username = item.username.toLowerCase();
return name.indexOf(query) > -1 || username.indexOf(query) > -1; return name.indexOf(query) > -1 || username.indexOf(query) > -1;
}); });
}, },
...@@ -46,8 +50,8 @@ export default { ...@@ -46,8 +50,8 @@ export default {
handleSearch(query) { handleSearch(query) {
this.query = query; this.query = query;
}, },
handleItemClick(assignee) { handleItemClick(item) {
this.$emit('onItemSelect', assignee); this.$emit('onItemSelect', item);
}, },
}, },
}; };
...@@ -61,12 +65,13 @@ export default { ...@@ -61,12 +65,13 @@ export default {
> >
<loading-icon /> <loading-icon />
</div> </div>
<assignees-list-filter <list-filter
@onSearchInput="handleSearch" @onSearchInput="handleSearch"
/> />
<assignees-list-content <list-content
v-if="!loading" v-if="!loading"
:assignees="filteredAssignees" :items="filteredItems"
:list-type="listType"
@onItemSelect="handleItemClick" @onItemSelect="handleItemClick"
/> />
</div> </div>
......
<script> <script>
import AssigneesListItem from './assignees_list_item.vue'; import AssigneesListItem from './assignees_list_item.vue';
import MilestoneListItem from './milestones_list_item.vue';
export default { export default {
components: {
AssigneesListItem,
},
props: { props: {
assignees: { items: {
type: Array, type: Array,
required: true, required: true,
}, },
listType: {
type: String,
required: true,
},
},
computed: {
listContentComponent() {
return this.listType === 'assignees' ? AssigneesListItem : MilestoneListItem;
},
}, },
methods: { methods: {
handleItemClick(assignee) { handleItemClick(item) {
this.$emit('onItemSelect', assignee); this.$emit('onItemSelect', item);
}, },
}, },
}; };
...@@ -22,10 +29,11 @@ export default { ...@@ -22,10 +29,11 @@ export default {
<template> <template>
<div class="dropdown-content"> <div class="dropdown-content">
<ul> <ul>
<assignees-list-item <component
v-for="assignee in assignees" v-for="item in items"
:key="assignee.id" :is="listContentComponent"
:assignee="assignee" :key="item.id"
:item="item"
@onItemSelect="handleItemClick" @onItemSelect="handleItemClick"
/> />
</ul> </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'; ...@@ -3,7 +3,8 @@ import $ from 'jquery';
import { throttle } from 'underscore'; import { throttle } from 'underscore';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import BoardForm from './board_form.vue'; 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 || {}; window.gl = window.gl || {};
...@@ -38,12 +39,14 @@ import AssigneesList from './assignees_list'; ...@@ -38,12 +39,14 @@ import AssigneesList from './assignees_list';
loading: true, loading: true,
hasScrollFade: false, hasScrollFade: false,
hasAssigneesListMounted: false, hasAssigneesListMounted: false,
hasMilestoneListMounted: false,
scrollFadeInitialized: false, scrollFadeInitialized: false,
boards: [], boards: [],
state: Store.state, state: Store.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0, contentClientHeight: 0,
maxPosition: 0, maxPosition: 0,
store: gl.issueBoards.BoardsStore,
}; };
}, },
computed: { computed: {
...@@ -84,6 +87,7 @@ import AssigneesList from './assignees_list'; ...@@ -84,6 +87,7 @@ import AssigneesList from './assignees_list';
created() { created() {
this.state.currentBoard = this.currentBoard; this.state.currentBoard = this.currentBoard;
Store.state.assignees = []; Store.state.assignees = [];
Store.state.milestones = [];
$('#js-add-list').on('hide.bs.dropdown', this.handleDropdownHide); $('#js-add-list').on('hide.bs.dropdown', this.handleDropdownHide);
$('.js-new-board-list-tabs').on('click', this.handleDropdownTabClick); $('.js-new-board-list-tabs').on('click', this.handleDropdownTabClick);
}, },
...@@ -146,13 +150,14 @@ import AssigneesList from './assignees_list'; ...@@ -146,13 +150,14 @@ import AssigneesList from './assignees_list';
$addListEl.data('preventClose', true); $addListEl.data('preventClose', true);
if (e.target.dataset.action === 'tab-assignees' && if (e.target.dataset.action === 'tab-assignees' &&
!this.hasAssigneesListMounted) { !this.hasAssigneesListMounted) {
this.assigneeList = new AssigneesList({ this.assigneeList = AssigneeList();
propsData: {
listAssigneesPath: $addListEl.find('.js-new-board-list').data('listAssigneesPath'),
},
}).$mount('.js-assignees-list');
this.hasAssigneesListMounted = true; 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 */ /* eslint-disable no-param-reassign */
import List from '~/boards/models/list'; import List from '~/boards/models/list';
import ListAssignee from '~/vue_shared/models/assignee'; import ListAssignee from '~/vue_shared/models/assignee';
import ListMilestone from '~/boards/models/milestone';
const EE_TYPES = { const EE_TYPES = {
promotion: { promotion: {
...@@ -47,7 +48,7 @@ class ListEE extends List { ...@@ -47,7 +48,7 @@ class ListEE extends List {
} }
onNewIssueResponse(issue, data) { onNewIssueResponse(issue, data) {
issue.milestone = data.milestone; issue.milestone = data.milestone ? new ListMilestone(data.milestone) : data.milestone;
issue.assignees = Array.isArray(data.assignees) issue.assignees = Array.isArray(data.assignees)
? data.assignees.map(assignee => new ListAssignee(assignee)) ? data.assignees.map(assignee => new ListAssignee(assignee))
: data.assignees; : data.assignees;
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
.dropdown .dropdown-menu.dropdown-menu-tabs { .dropdown .dropdown-menu.dropdown-menu-tabs {
padding-top: 0; padding-top: 0;
width: 240px; min-width: 240px;
.dropdown-tabs-list { .dropdown-tabs-list {
display: flex; 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 ...@@ -5,12 +5,12 @@ module EE
override :list_creation_attrs override :list_creation_attrs
def list_creation_attrs def list_creation_attrs
super + %i[assignee_id] super + %i[assignee_id milestone_id]
end end
override :serialization_attrs override :serialization_attrs
def serialization_attrs def serialization_attrs
super.merge(user: true) super.merge(user: true, milestone: true)
end end
end 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 ...@@ -8,7 +8,8 @@ module EE
override :board_list_data override :board_list_data
def 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 end
override :board_data override :board_data
......
...@@ -10,31 +10,34 @@ module EE ...@@ -10,31 +10,34 @@ module EE
end end
base.belongs_to :user base.belongs_to :user
base.belongs_to :milestone
base.validates :user, presence: true, if: :assignee? 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 :user_id, uniqueness: { scope: :board_id }, if: :assignee?
base.validates :milestone_id, uniqueness: { scope: :board_id }, if: :milestone?
base.validates :list_type, 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) } 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 end
def assignee=(user) def assignee=(user)
self.user = user self.user = user
end end
override :destroyable?
def destroyable?
assignee? || super
end
override :movable?
def movable?
assignee? || super
end
override :title override :title
def title def title
assignee? ? user.to_reference : super case list_type
when 'assignee'
user.to_reference
when 'milestone'
milestone.title
else
super
end
end end
override :as_json override :as_json
...@@ -43,16 +46,20 @@ module EE ...@@ -43,16 +46,20 @@ module EE
if options.key?(:user) if options.key?(:user)
json[:user] = UserSerializer.new.represent(user).as_json json[:user] = UserSerializer.new.represent(user).as_json
end end
if options.key?(:milestone)
json[:milestone] = MilestoneSerializer.new.represent(milestone).as_json
end
end end
end end
module ClassMethods module ClassMethods
def destroyable_types def destroyable_types
super + [:assignee] super + [:assignee, :milestone]
end end
def movable_types def movable_types
super + [:assignee] super + [:assignee, :milestone]
end end
end end
end end
......
...@@ -38,6 +38,7 @@ class License < ActiveRecord::Base ...@@ -38,6 +38,7 @@ class License < ActiveRecord::Base
admin_audit_log admin_audit_log
auditor_user auditor_user
board_assignee_lists board_assignee_lists
board_milestone_lists
cross_project_pipelines cross_project_pipelines
email_additional_text email_additional_text
db_load_balancing db_load_balancing
......
class MilestoneSerializer < BaseSerializer
entity API::Entities::Milestone
end
...@@ -7,10 +7,12 @@ module EE ...@@ -7,10 +7,12 @@ module EE
override :issue_params override :issue_params
def issue_params def issue_params
assignee_ids = Array(list.user_id || board.assignee&.id) 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], label_ids: [list.label_id, *board.label_ids],
weight: board.weight, weight: board.weight,
milestone_id: board.milestone_id, milestone_id: milestone_id,
# This can be removed when boards have multiple assignee support. # This can be removed when boards have multiple assignee support.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786 # See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
assignee_ids: assignee_ids assignee_ids: assignee_ids
......
...@@ -6,11 +6,19 @@ module EE ...@@ -6,11 +6,19 @@ module EE
override :filter override :filter
def filter(issues) def filter(issues)
issues = without_board_assignees(issues) unless list&.movable? || list&.closed? unless list&.movable? || list&.closed?
issues = without_assignees_from_lists(issues)
return super unless list&.assignee? issues = without_milestones_from_lists(issues)
end
case list&.list_type
when 'assignee'
with_assignee(super) with_assignee(super)
when 'milestone'
with_milestone(super)
else
super
end
end end
override :issues_label_links override :issues_label_links
...@@ -24,30 +32,49 @@ module EE ...@@ -24,30 +32,49 @@ module EE
private private
override :metadata_fields def all_assignee_lists
def metadata_fields if parent.feature_available?(:board_assignee_lists)
super.merge(total_weight: 'COALESCE(SUM(weight), 0)') board.lists.assignee.where.not(user_id: nil)
else
::List.none
end
end end
def board_assignee_ids def all_milestone_lists
@board_assignee_ids ||= if parent.feature_available?(:board_milestone_lists)
if parent.feature_available?(:board_assignee_lists) board.lists.milestone.where.not(milestone_id: nil)
board.lists.movable.pluck(:user_id).compact
else else
[] ::List.none
end end
end end
def without_board_assignees(issues) def without_assignees_from_lists(issues)
return issues unless board_assignee_ids.any? return issues if all_assignee_lists.empty?
issues
.where.not(id: issues.joins(:assignees).where(users: { id: all_assignee_lists.select(:user_id) }))
end
issues.where.not(id: issues.joins(:assignees).where(users: { id: board_assignee_ids })) override :metadata_fields
def metadata_fields
super.merge(total_weight: 'COALESCE(SUM(weight), 0)')
end
def without_milestones_from_lists(issues)
return issues if all_milestone_lists.empty?
issues.where("milestone_id NOT IN (?) OR milestone_id IS NULL",
all_milestone_lists.select(:milestone_id))
end end
def with_assignee(issues) def with_assignee(issues)
issues.assigned_to(list.user) issues.assigned_to(list.user)
end end
def with_milestone(issues)
issues.where(milestone_id: list.milestone_id)
end
# Prevent filtering by milestone stubs # Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc # like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone? def has_valid_milestone?
......
...@@ -14,7 +14,7 @@ module EE ...@@ -14,7 +14,7 @@ module EE
args.delete(:remove_label_ids) args.delete(:remove_label_ids)
end end
args.merge(assignee_ids: assignee_ids(issue)) args.merge(list_movement_args(issue))
end end
def both_are_list_type?(type) def both_are_list_type?(type)
...@@ -27,6 +27,25 @@ module EE ...@@ -27,6 +27,25 @@ module EE
moving_from_list.list_type == moving_to_list.list_type moving_from_list.list_type == moving_to_list.list_type
end 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) def assignee_ids(issue)
assignees = (issue.assignee_ids + [moving_to_list.user_id]).compact assignees = (issue.assignee_ids + [moving_to_list.user_id]).compact
......
...@@ -6,10 +6,16 @@ module EE ...@@ -6,10 +6,16 @@ module EE
override :type override :type
def type def type
return :assignee if params.keys.include?('assignee_id') # 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 super
end end
end
override :target override :target
def target(board) def target(board)
...@@ -17,17 +23,28 @@ module EE ...@@ -17,17 +23,28 @@ module EE
case type case type
when :assignee when :assignee
find_user(board) find_user(board)
when :milestone
find_milestone(board)
else else
super super
end end
end end
end end
def find_milestone(board)
milestones = milestone_finder(board).execute
milestones.find(params['milestone_id'])
end
def find_user(board) def find_user(board)
user_ids = user_finder(board).execute.select(:user_id) user_ids = user_finder(board).execute.select(:user_id)
::User.where(id: user_ids).find(params['assignee_id']) ::User.where(id: user_ids).find(params['assignee_id'])
end end
def milestone_finder(board)
@milestone_finder ||= ::Boards::MilestonesFinder.new(board, current_user)
end
def user_finder(board) def user_finder(board)
@user_finder ||= ::Boards::UsersFinder.new(board, current_user) @user_finder ||= ::Boards::UsersFinder.new(board, current_user)
end end
......
...@@ -2,13 +2,35 @@ module EE ...@@ -2,13 +2,35 @@ module EE
module Boards module Boards
module Lists module Lists
module ListService 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 extend ::Gitlab::Utils::Override
override :execute override :execute
def execute(board) 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
super.where.not(list_type: ::List.list_types[:assignee]) private
def list_type_features_availability(board)
parent = board.parent
{}.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 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 .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 } %button.btn.btn-create.btn-inverted.d-flex.js-new-board-list{ type: "button", data: board_list_data }
%span Add list %span Add list
...@@ -7,17 +10,30 @@ ...@@ -7,17 +10,30 @@
%ul.nav.nav-tabs.dropdown-tabs-list.js-new-board-list-tabs{ role: 'tablist' } %ul.nav.nav-tabs.dropdown-tabs-list.js-new-board-list-tabs{ role: 'tablist' }
%li.nav-item.dropdown-tab-item.js-tab-button-labels %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' } } %a.active{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-labels', target: '#tab-labels' } }
Label list Label
- if assignee_lists_available
%li.nav-item.dropdown-tab-item.js-tab-button-assignees %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' } } %a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-assignees', target: '#tab-assignees' } }
Assignee list 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-content
#tab-labels.tab-pane.tab-pane-labels.active.js-tab-container-labels{ role: 'tabpanel' } #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.') } = 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) - if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create", locals: { show_close: false } = render partial: "shared/issuable/label_page_create", locals: { show_close: false }
- if assignee_lists_available
#tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' } #tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' }
= render partial: "shared/issuable/assignee_page_default" = 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 = dropdown_loading
- else - else
= render_ce 'shared/issuable/board_create_list_dropdown', board: board = 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 ...@@ -34,6 +34,41 @@ module EE
end end
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 params :update_params do
optional :name, type: String, desc: 'The board name' optional :name, type: String, desc: 'The board name'
optional :assignee_id, type: Integer, desc: 'The ID of a user to associate with board' optional :assignee_id, type: Integer, desc: 'The ID of a user to associate with board'
......
...@@ -100,6 +100,15 @@ module EE ...@@ -100,6 +100,15 @@ module EE
end end
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 module ApplicationSetting
extend ActiveSupport::Concern 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 ...@@ -4,4 +4,11 @@ FactoryBot.define do
label nil label nil
user user
end end
factory :milestone_list, parent: :list do
list_type :milestone
label nil
user nil
milestone
end
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
}
import Vue from 'vue'; 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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data'; import { mockAssigneesList } from 'spec/boards/mock_data';
...@@ -9,7 +9,7 @@ const createComponent = () => { ...@@ -9,7 +9,7 @@ const createComponent = () => {
const Component = Vue.extend(AssigneesListItemComponent); const Component = Vue.extend(AssigneesListItemComponent);
return mountComponent(Component, { return mountComponent(Component, {
assignee: mockAssigneesList[0], item: mockAssigneesList[0],
}); });
}; };
......
...@@ -2,19 +2,20 @@ import '~/boards/stores/boards_store'; ...@@ -2,19 +2,20 @@ import '~/boards/stores/boards_store';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; 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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data'; import { mockAssigneesList } from 'spec/boards/mock_data';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
describe('AssigneesListComponent', () => { describe('BoardListSelectorComponent', () => {
const dummyEndpoint = `${TEST_HOST}/users.json`; const dummyEndpoint = `${TEST_HOST}/users.json`;
const createComponent = () => const createComponent = () =>
mountComponent(AssigneesListComponent, { mountComponent(BoardListSelectorComponent, {
listAssigneesPath: dummyEndpoint, listPath: dummyEndpoint,
listType: 'assignees',
}); });
let vm; let vm;
...@@ -43,13 +44,13 @@ describe('AssigneesListComponent', () => { ...@@ -43,13 +44,13 @@ describe('AssigneesListComponent', () => {
}); });
describe('methods', () => { describe('methods', () => {
describe('loadAssignees', () => { describe('loadList', () => {
it('calls axios.get and sets response to store.state.assignees', done => { it('calls axios.get and sets response to store.state.assignees', done => {
mock.onGet(dummyEndpoint).reply(200, mockAssigneesList); mock.onGet(dummyEndpoint).reply(200, mockAssigneesList);
gl.issueBoards.BoardsStore.state.assignees = []; gl.issueBoards.BoardsStore.state.assignees = [];
vm vm
.loadAssignees() .loadList()
.then(() => { .then(() => {
expect(vm.loading).toBe(false); expect(vm.loading).toBe(false);
expect(vm.store.state.assignees.length).toBe(mockAssigneesList.length); expect(vm.store.state.assignees.length).toBe(mockAssigneesList.length);
...@@ -63,7 +64,7 @@ describe('AssigneesListComponent', () => { ...@@ -63,7 +64,7 @@ describe('AssigneesListComponent', () => {
gl.issueBoards.BoardsStore.state.assignees = mockAssigneesList; gl.issueBoards.BoardsStore.state.assignees = mockAssigneesList;
vm vm
.loadAssignees() .loadList()
.then(() => { .then(() => {
expect(axios.get).not.toHaveBeenCalled(); expect(axios.get).not.toHaveBeenCalled();
}) })
...@@ -76,7 +77,7 @@ describe('AssigneesListComponent', () => { ...@@ -76,7 +77,7 @@ describe('AssigneesListComponent', () => {
gl.issueBoards.BoardsStore.state.assignees = []; gl.issueBoards.BoardsStore.state.assignees = [];
vm vm
.loadAssignees() .loadList()
.then(() => { .then(() => {
expect(vm.loading).toBe(false); expect(vm.loading).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe( expect(document.querySelector('.flash-text').innerText.trim()).toBe(
......
import Vue from 'vue'; 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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data'; import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(AssigneesListContainerComponent); const Component = Vue.extend(ListContainerComponent);
return mountComponent(Component, { return mountComponent(Component, {
loading: false, loading: false,
assignees: mockAssigneesList, items: mockAssigneesList,
listType: 'assignees',
}); });
}; };
describe('AssigneesListContainerComponent', () => { describe('ListContainerComponent', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
...@@ -26,26 +27,26 @@ describe('AssigneesListContainerComponent', () => { ...@@ -26,26 +27,26 @@ describe('AssigneesListContainerComponent', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('filteredAssignees', () => { describe('filteredItems', () => {
it('returns assignees list as it is when `query` is empty', () => { it('returns assignees list as it is when `query` is empty', () => {
vm.query = ''; 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', () => { it('returns filtered assignees list as it is when `query` has name', () => {
const assignee = mockAssigneesList[0]; const assignee = mockAssigneesList[0];
vm.query = assignee.name; vm.query = assignee.name;
expect(vm.filteredAssignees.length).toBe(1); expect(vm.filteredItems.length).toBe(1);
expect(vm.filteredAssignees[0].name).toBe(assignee.name); expect(vm.filteredItems[0].name).toBe(assignee.name);
}); });
it('returns filtered assignees list as it is when `query` has username', () => { it('returns filtered assignees list as it is when `query` has username', () => {
const assignee = mockAssigneesList[0]; const assignee = mockAssigneesList[0];
vm.query = assignee.username; vm.query = assignee.username;
expect(vm.filteredAssignees.length).toBe(1); expect(vm.filteredItems.length).toBe(1);
expect(vm.filteredAssignees[0].username).toBe(assignee.username); expect(vm.filteredItems[0].username).toBe(assignee.username);
}); });
}); });
}); });
......
import Vue from 'vue'; 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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data'; import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(AssigneesListContentComponent); const Component = Vue.extend(ListContentComponent);
return mountComponent(Component, { return mountComponent(Component, {
assignees: mockAssigneesList, items: mockAssigneesList,
listType: 'assignees',
}); });
}; };
describe('AssigneesListContentComponent', () => { describe('ListContentComponent', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
......
import Vue from 'vue'; 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'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(AssigneesListFilterComponent); const Component = Vue.extend(ListFilterComponent);
return mountComponent(Component); return mountComponent(Component);
}; };
describe('AssigneesListFilterComponent', () => { describe('ListFilterComponent', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
......
require 'rails_helper' require 'rails_helper'
describe List do 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) } subject { described_class.new(list_type: :assignee, board: board) }
it { is_expected.to be_destroyable } it { is_expected.to be_destroyable }
it { is_expected.to be_movable } it { is_expected.to be_movable }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do describe 'validations' do
it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:user) }
end end
...@@ -25,4 +26,30 @@ describe List do ...@@ -25,4 +26,30 @@ describe List do
end end
end 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 end
...@@ -7,4 +7,11 @@ describe API::Boards do ...@@ -7,4 +7,11 @@ describe API::Boards do
set(:board) { create(:board, project: board_parent, milestone: milestone) } set(:board) { create(:board, project: board_parent, milestone: milestone) }
it_behaves_like 'multiple and scoped issue boards', "/projects/:id/boards" 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 end
...@@ -46,4 +46,11 @@ describe API::GroupBoards do ...@@ -46,4 +46,11 @@ describe API::GroupBoards do
it_behaves_like 'group and project boards', "/groups/:id/boards", true it_behaves_like 'group and project boards', "/groups/:id/boards", true
it_behaves_like 'multiple and scoped issue boards', "/groups/:id/boards" 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 end
...@@ -6,19 +6,46 @@ describe Boards::Issues::CreateService do ...@@ -6,19 +6,46 @@ describe Boards::Issues::CreateService do
let(:board) { create(:board, project: project) } let(:board) { create(:board, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') } 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 before do
stub_licensed_features(board_assignee_lists: true)
project.add_developer(user) project.add_developer(user)
end end
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 it 'assigns the issue to the List assignee' do
issue = service.execute issue = service.execute
expect(issue.assignees).to eq([user]) expect(issue.assignees).to eq([user])
end end
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.milestone).to eq(milestone)
end
end
end
end end
...@@ -20,6 +20,7 @@ describe Boards::Issues::ListService, services: true do ...@@ -20,6 +20,7 @@ describe Boards::Issues::ListService, services: true do
let(:p3) { create(:group_label, title: 'P3', group: group) } let(:p3) { create(:group_label, title: 'P3', group: group) }
let(:user_list) { create(:user_list, board: board, position: 2) } 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(:backlog) { create(:backlog_list, board: board) }
let(:list1) { create(:list, board: board, label: development, position: 0) } let(:list1) { create(:list, board: board, label: development, position: 0) }
let(:list2) { create(:list, board: board, label: testing, position: 1) } let(:list2) { create(:list, board: board, label: testing, position: 1) }
...@@ -44,11 +45,36 @@ describe Boards::Issues::ListService, services: true do ...@@ -44,11 +45,36 @@ describe Boards::Issues::ListService, services: true do
let(:parent) { group } let(:parent) { group }
before do before do
stub_licensed_features(board_assignee_lists: true) stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
parent.add_developer(user) parent.add_developer(user)
opened_issue3.assignees.push(user_list.user) opened_issue3.assignees.push(user_list.user)
end 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 context '#metadata' do
it 'returns issues count and weight for list' do it 'returns issues count and weight for list' do
params = { board_id: board.id, id: backlog.id } params = { board_id: board.id, id: backlog.id }
...@@ -72,7 +98,7 @@ describe Boards::Issues::ListService, services: true do ...@@ -72,7 +98,7 @@ describe Boards::Issues::ListService, services: true do
end end
context 'when list_id is missing' do 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 it 'returns opened issues without board labels and assignees applied' do
params = { board_id: board.id } params = { board_id: board.id }
...@@ -82,14 +108,15 @@ describe Boards::Issues::ListService, services: true do ...@@ -82,14 +108,15 @@ describe Boards::Issues::ListService, services: true do
end end
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 it 'returns opened issues without board labels, assignees, or milestone applied' do
params = { board_id: board.id } params = { board_id: board.id }
board.update_attribute(:milestone, m1) board.update_attribute(:milestone, m1)
issues = described_class.new(parent, user, params).execute 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 end
context 'when milestone is predefined' do context 'when milestone is predefined' do
......
...@@ -2,18 +2,17 @@ require 'spec_helper' ...@@ -2,18 +2,17 @@ require 'spec_helper'
describe Boards::Lists::CreateService do describe Boards::Lists::CreateService do
describe '#execute' do describe '#execute' do
let(:parent) { create(:project) } let(:project) { create(:project) }
let(:board) { create(:board, project: parent) } let(:board) { create(:board, project: project) }
let(:label) { create(:label, project: parent, name: 'in-progress') }
context 'when assignee_id param is sent' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:other_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 before do
parent.add_developer(user) project.add_developer(user)
parent.add_developer(other_user) project.add_developer(other_user)
stub_licensed_features(board_assignee_lists: true) stub_licensed_features(board_assignee_lists: true)
end end
...@@ -25,4 +24,24 @@ describe Boards::Lists::CreateService do ...@@ -25,4 +24,24 @@ describe Boards::Lists::CreateService do
expect(list).to be_valid expect(list).to be_valid
end end
end end
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('milestone')
expect(list).to be_valid
end
end
end
end end
...@@ -10,6 +10,7 @@ describe Boards::Lists::ListService do ...@@ -10,6 +10,7 @@ describe Boards::Lists::ListService do
context 'when the feature is enabled' do context 'when the feature is enabled' do
before 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_assignee_lists).and_return(true)
allow(board.parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(false)
end end
it 'returns all lists' do it 'returns all lists' do
...@@ -24,6 +25,30 @@ describe Boards::Lists::ListService do ...@@ -24,6 +25,30 @@ describe Boards::Lists::ListService do
end end
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 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) }
...@@ -31,6 +56,7 @@ describe Boards::Lists::ListService do ...@@ -31,6 +56,7 @@ describe Boards::Lists::ListService do
let(:service) { described_class.new(project, double) } 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 assignee lists'
it_behaves_like 'list service for board with milestone lists'
end end
context 'when board parent is a group' do context 'when board parent is a group' do
...@@ -40,6 +66,7 @@ describe Boards::Lists::ListService do ...@@ -40,6 +66,7 @@ describe Boards::Lists::ListService do
let(:service) { described_class.new(group, double) } 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 assignee lists'
it_behaves_like 'list service for board with milestone lists'
end end
end 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 module API
class Boards < Grape::API class Boards < Grape::API
include BoardsResponses include BoardsResponses
include EE::API::BoardsResponses
include PaginationParams include PaginationParams
before { authenticate! } before { authenticate! }
...@@ -71,12 +72,10 @@ module API ...@@ -71,12 +72,10 @@ module API
success Entities::List success Entities::List
end end
params do params do
requires :label_id, type: Integer, desc: 'The ID of an existing label' use :list_creation_params
end end
post '/lists' do post '/lists' do
unless available_labels_for(user_project).exists?(params[:label_id]) authorize_list_type_resource!
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize!(:admin_list, user_project) authorize!(:admin_list, user_project)
......
...@@ -14,7 +14,7 @@ module API ...@@ -14,7 +14,7 @@ module API
def create_list def create_list
create_list_service = 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) list = create_list_service.execute(board)
...@@ -25,6 +25,10 @@ module API ...@@ -25,6 +25,10 @@ module API
end end
end end
def create_list_params
params.slice(:label_id)
end
def move_list(list) def move_list(list)
move_list_service = move_list_service =
::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i }) ::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i })
...@@ -44,6 +48,16 @@ module API ...@@ -44,6 +48,16 @@ module API
end end
end 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 end
end end
......
...@@ -1453,6 +1453,7 @@ API::Entities.prepend_entity(::API::Entities::Board, with: EE::API::Entities::Bo ...@@ -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::Group, with: EE::API::Entities::Group)
API::Entities.prepend_entity(::API::Entities::GroupDetail, with: EE::API::Entities::GroupDetail) 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::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::MergeRequestBasic, with: EE::API::Entities::MergeRequestBasic)
API::Entities.prepend_entity(::API::Entities::Namespace, with: EE::API::Entities::Namespace) API::Entities.prepend_entity(::API::Entities::Namespace, with: EE::API::Entities::Namespace)
API::Entities.prepend_entity(::API::Entities::Project, with: EE::API::Entities::Project) API::Entities.prepend_entity(::API::Entities::Project, with: EE::API::Entities::Project)
......
...@@ -71,12 +71,10 @@ module API ...@@ -71,12 +71,10 @@ module API
success Entities::List success Entities::List
end end
params do params do
requires :label_id, type: Integer, desc: 'The ID of an existing label' use :list_creation_params
end end
post '/lists' do post '/lists' do
unless available_labels_for(board_parent).exists?(params[:label_id]) authorize_list_type_resource!
render_api_error!({ error: 'Label not found!' }, 400)
end
authorize!(:admin_list, user_group) authorize!(:admin_list, user_group)
......
...@@ -723,7 +723,7 @@ msgstr "" ...@@ -723,7 +723,7 @@ msgstr ""
msgid "Assignee" msgid "Assignee"
msgstr "" msgstr ""
msgid "Assignee boards not available with your current license" msgid "Assignee lists not available with your current license"
msgstr "" msgstr ""
msgid "Assignee lists show all issues assigned to the selected user." msgid "Assignee lists show all issues assigned to the selected user."
...@@ -4357,6 +4357,12 @@ msgstr "" ...@@ -4357,6 +4357,12 @@ msgstr ""
msgid "Milestone" msgid "Milestone"
msgstr "" msgstr ""
msgid "Milestone lists not available with your current license"
msgstr ""
msgid "Milestone lists show all issues from the selected milestone."
msgstr ""
msgid "Milestones" msgid "Milestones"
msgstr "" msgstr ""
...@@ -6046,7 +6052,7 @@ msgstr "" ...@@ -6046,7 +6052,7 @@ msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later" msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr "" msgstr ""
msgid "Something went wrong while fetching assignees list" msgid "Something went wrong while fetching %{listType} list"
msgstr "" msgstr ""
msgid "Something went wrong while fetching group member contributions" 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