Skip to content
Snippets Groups Projects
Commit cf41aaba authored by Mario de la Ossa's avatar Mario de la Ossa Committed by Sean McGivern
Browse files

Backport of "Add assignee lists to boards"

parent d4357afd
No related branches found
No related tags found
No related merge requests found
Showing
with 232 additions and 100 deletions
Loading
Loading
@@ -87,10 +87,46 @@ export default {
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: true,
group: 'issues',
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
group: {
name: 'issues',
/**
* Dynamically determine between which containers
* items can be moved or copied as
* Assignee lists (EE feature) require this behavior
*/
pull: (to, from, dragEl, e) => {
// As per Sortable's docs, `to` should provide
// reference to exact sortable container on which
// we're trying to drag element, but either it is
// a library's bug or our markup structure is too complex
// that `to` never points to correct container
// See https://github.com/RubaXa/Sortable/issues/1037
//
// So we use `e.target` which is always accurate about
// which element we're currently dragging our card upon
// So from there, we can get reference to actual container
// and thus the container type to enable Copy or Move
if (e.target) {
const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
if (toBoardType) {
const fromBoardType = this.list.type;
if ((fromBoardType === 'assignee' && toBoardType === 'label') ||
(fromBoardType === 'label' && toBoardType === 'assignee')) {
return 'clone';
}
}
}
return true;
},
revertClone: true,
},
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
 
Loading
Loading
@@ -179,10 +215,11 @@ export default {
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
class="board-list"
class="board-list js-board-list"
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }">
<board-card
v-for="(issue, index) in issues"
Loading
Loading
Loading
Loading
@@ -49,11 +49,12 @@ export default {
this.error = false;
 
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees: [],
assignees,
project_id: this.selectedProject.id,
});
 
Loading
Loading
@@ -141,4 +142,3 @@ export default {
</div>
</div>
</template>
Loading
Loading
@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content',
clicked (options) {
const { e } = options;
const label = options.selectedObj;
Loading
Loading
Loading
Loading
@@ -7,6 +7,7 @@ import Vue from 'vue';
import Flash from '~/flash';
import { __ } from '~/locale';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
 
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
Loading
Loading
@@ -15,7 +16,6 @@ import './models/issue';
import './models/list';
import './models/milestone';
import './models/project';
import './models/assignee';
import './stores/boards_store';
import ModalStore from './stores/modal_store';
import BoardService from './services/board_service';
Loading
Loading
/* eslint-disable no-unused-vars */
class ListAssignee {
constructor(user, defaultAvatar) {
this.id = user.id;
this.name = user.name;
this.username = user.username;
this.avatar = user.avatar_url || defaultAvatar;
}
}
window.ListAssignee = ListAssignee;
/* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */
/* global ListIssue */
/* global ListLabel */
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data';
 
const PER_PAGE = 20;
 
class List {
constructor (obj, defaultAvatar) {
constructor(obj, defaultAvatar) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
Loading
Loading
@@ -24,6 +26,9 @@ class List {
 
if (obj.label) {
this.label = new ListLabel(obj.label);
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
}
 
if (this.type !== 'blank' && this.id) {
Loading
Loading
@@ -34,14 +39,25 @@ class List {
}
 
guid() {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
const s4 = () =>
Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
}
 
save () {
save() {
const entity = this.label || this.assignee;
let entityType = '';
if (this.label) {
entityType = 'label_id';
} else {
entityType = 'assignee_id';
}
return gl.boardService.createList(this.label.id)
.then(res => res.data)
.then((data) => {
.then(data => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
Loading
Loading
@@ -50,25 +66,23 @@ class List {
});
}
 
destroy () {
destroy() {
const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this);
gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
 
gl.boardService.destroyList(this.id)
.catch(() => {
// TODO: handle request error
});
gl.boardService.destroyList(this.id).catch(() => {
// TODO: handle request error
});
}
 
update () {
gl.boardService.updateList(this.id, this.position)
.catch(() => {
// TODO: handle request error
});
update() {
gl.boardService.updateList(this.id, this.position).catch(() => {
// TODO: handle request error
});
}
 
nextPage () {
nextPage() {
if (this.issuesSize > this.issues.length) {
if (this.issues.length / PER_PAGE >= 1) {
this.page += 1;
Loading
Loading
@@ -78,7 +92,7 @@ class List {
}
}
 
getIssues (emptyIssues = true) {
getIssues(emptyIssues = true) {
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
 
if (this.label && data.label_name) {
Loading
Loading
@@ -89,7 +103,8 @@ class List {
this.loading = true;
}
 
return gl.boardService.getIssuesForList(this.id, data)
return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data)
.then((data) => {
this.loading = false;
Loading
Loading
@@ -103,11 +118,12 @@ class List {
});
}
 
newIssue (issue) {
newIssue(issue) {
this.addIssue(issue, null, 0);
this.issuesSize += 1;
 
return gl.boardService.newIssue(this.id, issue)
return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data)
.then((data) => {
issue.id = data.id;
Loading
Loading
@@ -123,13 +139,13 @@ class List {
});
}
 
createIssues (data) {
data.forEach((issueObj) => {
createIssues(data) {
data.forEach(issueObj => {
this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
 
addIssue (issue, listFrom, newIndex) {
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
 
Loading
Loading
@@ -152,6 +168,13 @@ class List {
issue.addLabel(this.label);
}
 
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (listFrom) {
this.issuesSize += 1;
 
Loading
Loading
@@ -160,29 +183,29 @@ class List {
}
}
 
moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
 
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
// TODO: handle request error
});
}
 
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
}
 
findIssue (id) {
findIssue(id) {
return this.issues.find(issue => issue.id === id);
}
 
removeIssue (removeIssue) {
this.issues = this.issues.filter((issue) => {
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
 
if (matchesRemove) {
Loading
Loading
Loading
Loading
@@ -30,11 +30,13 @@ export default class BoardService {
return axios.post(this.listsEndpointGenerate, {});
}
 
createList(labelId) {
createList(entityId, entityType) {
const list = {
[entityType]: entityId,
};
return axios.post(this.listsEndpoint, {
list: {
label_id: labelId,
},
list,
});
}
 
Loading
Loading
Loading
Loading
@@ -103,8 +103,15 @@ gl.issueBoards.BoardsStore = {
const listLabels = issueLists.map(listIssue => listIssue.label);
 
if (!issueTo) {
// Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex);
// Check if target list assignee is already present in this issue
if ((listTo.type === 'assignee' && listFrom.type === 'assignee') &&
issue.findAssignee(listTo.assignee)) {
const targetIssue = listTo.findIssue(issue.id);
targetIssue.removeAssignee(listFrom.assignee);
} else {
// Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex);
}
} else {
listTo.updateIssueLabel(issue, listFrom);
issueTo.removeLabel(listFrom.label);
Loading
Loading
@@ -115,7 +122,11 @@ gl.issueBoards.BoardsStore = {
list.removeIssue(issue);
});
issue.removeLabels(listLabels);
} else {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue);
} else if ((listTo.type !== 'label' && listFrom.type === 'assignee') ||
(listTo.type !== 'assignee' && listFrom.type === 'label')) {
listFrom.removeIssue(issue);
}
},
Loading
Loading
@@ -126,11 +137,12 @@ gl.issueBoards.BoardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
findList (key, val, type = 'label') {
return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true;
const filteredList = this.state.lists.filter((list) => {
const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
 
return list[key] === val && byType;
})[0];
});
return filteredList[0];
},
updateFiltersUrl () {
history.pushState(null, null, `?${this.filter.path}`);
Loading
Loading
Loading
Loading
@@ -602,7 +602,11 @@ GitLabDropdown = (function() {
var selector;
selector = '.dropdown-content';
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one .dropdown-content";
if (this.options.containerSelector) {
selector = this.options.containerSelector;
} else {
selector = '.dropdown-page-one .dropdown-content';
}
}
 
return $(selector, this.dropdown).empty();
Loading
Loading
export default class ListAssignee {
constructor(obj, defaultAvatar) {
this.id = obj.id;
this.name = obj.name;
this.username = obj.username;
this.avatar = obj.avatar_url || obj.avatar || defaultAvatar;
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
}
}
window.ListAssignee = ListAssignee;
Loading
Loading
@@ -56,8 +56,12 @@ module Boards
 
private
 
def list_creation_attrs
%i[label_id]
end
def list_params
params.require(:list).permit(:label_id)
params.require(:list).permit(list_creation_attrs)
end
 
def move_params
Loading
Loading
@@ -65,11 +69,15 @@ module Boards
end
 
def serialize_as_json(resource)
resource.as_json(
resource.as_json(serialization_attrs)
end
def serialization_attrs
{
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
}
end
end
end
Loading
Loading
@@ -3,17 +3,29 @@ class GroupMembersFinder
@group = group
end
 
def execute
def execute(include_descendants: false)
group_members = @group.members
wheres = []
 
return group_members unless @group.parent
return group_members unless @group.parent || include_descendants
 
parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id))
wheres << "members.id IN (#{group_members.select(:id).to_sql})"
 
wheres = ["members.id IN (#{group_members.select(:id).to_sql})"]
wheres << "members.id IN (#{parents_members.select(:id).to_sql})"
if @group.parent
parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id))
wheres << "members.id IN (#{parents_members.select(:id).to_sql})"
end
if include_descendants
descendant_members = GroupMember.non_request
.where(source_id: @group.descendants.select(:id))
.where.not(user_id: @group.users.select(:id))
wheres << "members.id IN (#{descendant_members.select(:id).to_sql})"
end
 
GroupMember.where(wheres.join(' OR '))
end
Loading
Loading
Loading
Loading
@@ -7,12 +7,12 @@ class MembersFinder
@group = project.group
end
 
def execute
def execute(include_descendants: false)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
 
if group
group_members = GroupMembersFinder.new(group).execute
group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants)
group_members = group_members.non_invite
 
union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
Loading
Loading
Loading
Loading
@@ -2,17 +2,27 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
 
enum list_type: { backlog: 0, label: 1, closed: 2 }
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
 
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
validates :label_id, uniqueness: { scope: :board_id }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :movable?
 
before_destroy :can_be_destroyed
 
scope :destroyable, -> { where(list_type: list_types[:label]) }
scope :movable, -> { where(list_type: list_types[:label]) }
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
class << self
def destroyable_types
[:label]
end
def movable_types
[:label]
end
end
 
def destroyable?
label?
Loading
Loading
Loading
Loading
@@ -3,13 +3,18 @@ module Boards
class ListService < Boards::BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
issues = with_list_label(issues) if movable_list?
issues = filter(issues)
issues.order_by_position_and_priority
end
 
private
 
def filter(issues)
issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label?
issues
end
def board
@board ||= parent.boards.find(params[:board_id])
end
Loading
Loading
@@ -20,18 +25,6 @@ module Boards
@list = board.lists.find(params[:id]) if params.key?(:id)
end
 
def movable_list?
return @movable_list if defined?(@movable_list)
@movable_list = list.present? && list.movable?
end
def closed_list?
return @closed_list if defined?(@closed_list)
@closed_list = list.present? && list.closed?
end
def filter_params
set_parent
set_state
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@ module Boards
class MoveService < Boards::BaseService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty?
return false if issue_params(issue).empty?
 
update(issue)
end
Loading
Loading
@@ -28,10 +28,10 @@ module Boards
end
 
def update(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params(issue)).execute(issue)
end
 
def issue_params
def issue_params(issue)
attrs = {}
 
if move_between_lists?
Loading
Loading
module Boards
module Lists
class CreateService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def execute(board)
List.transaction do
label = available_labels_for(board).find(params[:label_id])
target = target(board)
position = next_position(board)
create_list(board, label, position)
create_list(board, type, target, position)
end
end
 
private
 
def type
:label
end
def target(board)
strong_memoize(:target) do
available_labels_for(board).find(params[:label_id])
end
end
def available_labels_for(board)
options = { include_ancestor_groups: true }
 
Loading
Loading
@@ -28,8 +40,8 @@ module Boards
max_position.nil? ? 0 : max_position.succ
end
 
def create_list(board, label, position)
board.lists.create(label: label, list_type: :label, position: position)
def create_list(board, type, target, position)
board.lists.create(type => target, list_type: type, position: position)
end
end
end
Loading
Loading
.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }',
.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
":data-id" => "list.id" }
.board-inner
%header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
Loading
Loading
@@ -7,10 +7,18 @@
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
 
%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\"",
":title" => '(list.label ? list.label.description : "")', data: { container: "body" } }
":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }}
 
%span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
":title" => '(list.assignee && list.assignee.username || "")' }
@{{ list.assignee.username }}
%span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" },
Loading
Loading
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- show_close = local_assigns.fetch(:show_close, true)
- subject = @project || @group
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true })
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
= dropdown_content do
.dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment