Commit b0c6466e authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ce-5784-user-board-lists' into 'master'

Backport of "Add assignee lists to boards"

See merge request gitlab-org/gitlab-ce!19464
parents d4357afd cf41aaba
...@@ -87,10 +87,46 @@ export default { ...@@ -87,10 +87,46 @@ export default {
mounted() { mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({ const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: true, scroll: true,
group: 'issues',
disabled: this.disabled, disabled: this.disabled,
filter: '.board-list-count, .is-disabled', filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id', 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) => { onStart: (e) => {
const card = this.$refs.issue[e.oldIndex]; const card = this.$refs.issue[e.oldIndex];
...@@ -179,10 +215,11 @@ export default { ...@@ -179,10 +215,11 @@ export default {
:list="list" :list="list"
v-if="list.type !== 'closed' && showIssueForm"/> v-if="list.type !== 'closed' && showIssueForm"/>
<ul <ul
class="board-list" class="board-list js-board-list"
v-show="!loading" v-show="!loading"
ref="list" ref="list"
:data-board="list.id" :data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }"> :class="{ 'is-smaller': showIssueForm }">
<board-card <board-card
v-for="(issue, index) in issues" v-for="(issue, index) in issues"
......
...@@ -49,11 +49,12 @@ export default { ...@@ -49,11 +49,12 @@ export default {
this.error = false; this.error = false;
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 issue = new ListIssue({ const issue = new ListIssue({
title: this.title, title: this.title,
labels, labels,
subscribed: true, subscribed: true,
assignees: [], assignees,
project_id: this.selectedProject.id, project_id: this.selectedProject.id,
}); });
...@@ -141,4 +142,3 @@ export default { ...@@ -141,4 +142,3 @@ export default {
</div> </div>
</div> </div>
</template> </template>
...@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => { ...@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true, filterable: true,
selectable: true, selectable: true,
multiSelect: true, multiSelect: true,
containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content',
clicked (options) { clicked (options) {
const { e } = options; const { e } = options;
const label = options.selectedObj; const label = options.selectedObj;
......
...@@ -7,6 +7,7 @@ import Vue from 'vue'; ...@@ -7,6 +7,7 @@ import Vue from 'vue';
import Flash from '~/flash'; import Flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import FilteredSearchBoards from './filtered_search_boards'; import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub'; import eventHub from './eventhub';
...@@ -15,7 +16,6 @@ import './models/issue'; ...@@ -15,7 +16,6 @@ import './models/issue';
import './models/list'; import './models/list';
import './models/milestone'; import './models/milestone';
import './models/project'; import './models/project';
import './models/assignee';
import './stores/boards_store'; import './stores/boards_store';
import ModalStore from './stores/modal_store'; import ModalStore from './stores/modal_store';
import BoardService from './services/board_service'; import BoardService from './services/board_service';
......
/* 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 */ /* 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 ListIssue */
/* global ListLabel */
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data'; import queryData from '../utils/query_data';
const PER_PAGE = 20; const PER_PAGE = 20;
class List { class List {
constructor (obj, defaultAvatar) { constructor(obj, defaultAvatar) {
this.id = obj.id; this.id = obj.id;
this._uid = this.guid(); this._uid = this.guid();
this.position = obj.position; this.position = obj.position;
...@@ -24,6 +26,9 @@ class List { ...@@ -24,6 +26,9 @@ class List {
if (obj.label) { if (obj.label) {
this.label = new ListLabel(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) { if (this.type !== 'blank' && this.id) {
...@@ -34,14 +39,25 @@ class List { ...@@ -34,14 +39,25 @@ class List {
} }
guid() { 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()}`; 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) return gl.boardService.createList(this.label.id)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
this.id = data.id; this.id = data.id;
this.type = data.list_type; this.type = data.list_type;
this.position = data.position; this.position = data.position;
...@@ -50,25 +66,23 @@ class List { ...@@ -50,25 +66,23 @@ class List {
}); });
} }
destroy () { destroy() {
const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this); const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this);
gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
gl.boardService.destroyList(this.id) gl.boardService.destroyList(this.id).catch(() => {
.catch(() => { // TODO: handle request error
// TODO: handle request error });
});
} }
update () { update() {
gl.boardService.updateList(this.id, this.position) gl.boardService.updateList(this.id, this.position).catch(() => {
.catch(() => { // TODO: handle request error
// TODO: handle request error });
});
} }
nextPage () { nextPage() {
if (this.issuesSize > this.issues.length) { if (this.issuesSize > this.issues.length) {
if (this.issues.length / PER_PAGE >= 1) { if (this.issues.length / PER_PAGE >= 1) {
this.page += 1; this.page += 1;
...@@ -78,7 +92,7 @@ class List { ...@@ -78,7 +92,7 @@ class List {
} }
} }
getIssues (emptyIssues = true) { getIssues(emptyIssues = true) {
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page }); const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
if (this.label && data.label_name) { if (this.label && data.label_name) {
...@@ -89,7 +103,8 @@ class List { ...@@ -89,7 +103,8 @@ class List {
this.loading = true; this.loading = true;
} }
return gl.boardService.getIssuesForList(this.id, data) return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then((data) => {
this.loading = false; this.loading = false;
...@@ -103,11 +118,12 @@ class List { ...@@ -103,11 +118,12 @@ class List {
}); });
} }
newIssue (issue) { newIssue(issue) {
this.addIssue(issue, null, 0); this.addIssue(issue, null, 0);
this.issuesSize += 1; this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue) return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then((data) => {
issue.id = data.id; issue.id = data.id;
...@@ -123,13 +139,13 @@ class List { ...@@ -123,13 +139,13 @@ class List {
}); });
} }
createIssues (data) { createIssues(data) {
data.forEach((issueObj) => { data.forEach(issueObj => {
this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
}); });
} }
addIssue (issue, listFrom, newIndex) { addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null; let moveBeforeId = null;
let moveAfterId = null; let moveAfterId = null;
...@@ -152,6 +168,13 @@ class List { ...@@ -152,6 +168,13 @@ class List {
issue.addLabel(this.label); issue.addLabel(this.label);
} }
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (listFrom) { if (listFrom) {
this.issuesSize += 1; this.issuesSize += 1;
...@@ -160,29 +183,29 @@ class List { ...@@ -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(oldIndex, 1);
this.issues.splice(newIndex, 0, issue); this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId) gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
.catch(() => { // TODO: handle request error
// TODO: handle request error });
});
} }
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { 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(() => { .catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
findIssue (id) { findIssue(id) {
return this.issues.find(issue => issue.id === id); return this.issues.find(issue => issue.id === id);
} }
removeIssue (removeIssue) { removeIssue(removeIssue) {
this.issues = this.issues.filter((issue) => { this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id; const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) { if (matchesRemove) {
......
...@@ -30,11 +30,13 @@ export default class BoardService { ...@@ -30,11 +30,13 @@ export default class BoardService {
return axios.post(this.listsEndpointGenerate, {}); return axios.post(this.listsEndpointGenerate, {});
} }
createList(labelId) { createList(entityId, entityType) {
const list = {
[entityType]: entityId,
};
return axios.post(this.listsEndpoint, { return axios.post(this.listsEndpoint, {
list: { list,
label_id: labelId,
},
}); });
} }
......
...@@ -103,8 +103,15 @@ gl.issueBoards.BoardsStore = { ...@@ -103,8 +103,15 @@ gl.issueBoards.BoardsStore = {
const listLabels = issueLists.map(listIssue => listIssue.label); const listLabels = issueLists.map(listIssue => listIssue.label);
if (!issueTo) { if (!issueTo) {
// Add to new lists issues if it doesn't already exist // Check if target list assignee is already present in this issue
listTo.addIssue(issue, listFrom, newIndex); 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 { } else {
listTo.updateIssueLabel(issue, listFrom); listTo.updateIssueLabel(issue, listFrom);
issueTo.removeLabel(listFrom.label); issueTo.removeLabel(listFrom.label);
...@@ -115,7 +122,11 @@ gl.issueBoards.BoardsStore = { ...@@ -115,7 +122,11 @@ gl.issueBoards.BoardsStore = {
list.removeIssue(issue); list.removeIssue(issue);
}); });
issue.removeLabels(listLabels); 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); listFrom.removeIssue(issue);
} }
}, },
...@@ -126,11 +137,12 @@ gl.issueBoards.BoardsStore = { ...@@ -126,11 +137,12 @@ gl.issueBoards.BoardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
}, },
findList (key, val, type = 'label') { findList (key, val, type = 'label') {
return this.state.lists.filter((list) => { const filteredList = this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true; const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
return list[key] === val && byType; return list[key] === val && byType;
})[0]; });
return filteredList[0];
}, },
updateFiltersUrl () { updateFiltersUrl () {
history.pushState(null, null, `?${this.filter.path}`); history.pushState(null, null, `?${this.filter.path}`);
......
...@@ -602,7 +602,11 @@ GitLabDropdown = (function() { ...@@ -602,7 +602,11 @@ GitLabDropdown = (function() {
var selector; var selector;
selector = '.dropdown-content'; selector = '.dropdown-content';
if (this.dropdown.find(".dropdown-toggle-page").length) { 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(); return $(selector, this.dropdown).empty();
......
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;
...@@ -56,8 +56,12 @@ module Boards ...@@ -56,8 +56,12 @@ module Boards
private private
def list_creation_attrs
%i[label_id]
end
def list_params def list_params
params.require(:list).permit(:label_id) params.require(:list).permit(list_creation_attrs)
end end
def move_params def move_params
...@@ -65,11 +69,15 @@ module Boards ...@@ -65,11 +69,15 @@ module Boards
end end
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json( resource.as_json(serialization_attrs)
end
def serialization_attrs
{
only: [:id, :list_type, :position], only: [:id, :list_type, :position],
methods: [:title], methods: [:title],
label: true label: true
) }
end end
end end
end end
...@@ -3,17 +3,29 @@ class GroupMembersFinder ...@@ -3,17 +3,29 @@ class GroupMembersFinder
@group = group @group = group
end end
def execute def execute(include_descendants: false)
group_members = @group.members group_members = @group.members
wheres = []
return group_members unless @group.parent return group_members unless @group.parent || include_descendants
parents_members = GroupMember.non_request wheres << "members.id IN (#{group_members.select(:id).to_sql})"
.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})"] if @group.parent
wheres << "members.id IN (#{parents_members.select(:id).to_sql})" 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 ')) GroupMember.where(wheres.join(' OR '))
end end
......
...@@ -7,12 +7,12 @@ class MembersFinder ...@@ -7,12 +7,12 @@ class MembersFinder
@group = project.group @group = project.group
end end
def execute def execute(include_descendants: false)
project_members = project.project_members project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project) project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
if group if group
group_members = GroupMembersFinder.new(group).execute group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants)
group_members = group_members.non_invite group_members = group_members.non_invite
union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
......
...@@ -2,17 +2,27 @@ class List < ActiveRecord::Base ...@@ -2,17 +2,27 @@ 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 } enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
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?
validates :label_id, uniqueness: { scope: :board_id }, 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 before_destroy :can_be_destroyed
scope :destroyable, -> { where(list_type: list_types[:label]) } scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types[:label]) } 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? def destroyable?
label? label?
......
...@@ -3,13 +3,18 @@ module Boards ...@@ -3,13 +3,18 @@ module Boards
class ListService < Boards::BaseService class ListService < Boards::BaseService
def execute def execute
issues = IssuesFinder.new(current_user, filter_params).execute issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list? issues = filter(issues)
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority issues.order_by_position_and_priority
end end
private 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 def board
@board ||= parent.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
...@@ -20,18 +25,6 @@ module Boards ...@@ -20,18 +25,6 @@ module Boards
@list = board.lists.find(params[:id]) if params.key?(:id) @list = board.lists.find(params[:id]) if params.key?(:id)
end 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 def filter_params
set_parent set_parent
set_state set_state
......
...@@ -3,7 +3,7 @@ module Boards ...@@ -3,7 +3,7 @@ module Boards
class MoveService < Boards::BaseService class MoveService < Boards::BaseService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, 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) update(issue)
end end
...@@ -28,10 +28,10 @@ module Boards ...@@ -28,10 +28,10 @@ module Boards
end end
def update(issue) 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 end
def issue_params def issue_params(issue)
attrs = {} attrs = {}
if move_between_lists? if move_between_lists?
......
module Boards module Boards
module Lists module Lists
class CreateService < Boards::BaseService class CreateService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def execute(board) def execute(board)
List.transaction do List.transaction do
label = available_labels_for(board).find(params[:label_id]) target = target(board)
position = next_position(board) position = next_position(board)
create_list(board, label, position) create_list(board, type, target, position)
end end
end end
private 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) def available_labels_for(board)
options = { include_ancestor_groups: true } options = { include_ancestor_groups: true }
...@@ -28,8 +40,8 @@ module Boards ...@@ -28,8 +40,8 @@ module Boards
max_position.nil? ? 0 : max_position.succ max_position.nil? ? 0 : max_position.succ
end end
def create_list(board, label, position) def create_list(board, type, target, position)
board.lists.create(label: label, list_type: :label, position: position) board.lists.create(type => target, list_type: type, position: position)
end end
end end
end end
......
.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" } ":data-id" => "list.id" }
.board-inner .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)" } %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)" }
...@@ -7,10 +7,18 @@ ...@@ -7,10 +7,18 @@
":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" }
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"", %span.board-title-text.has-tooltip{ "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 }} {{ 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\"", %span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")', ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" }, data: { container: "body", placement: "bottom" },
......
.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 - subject = @project || @group
.dropdown-page-two.dropdown-new-label .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_content do
.dropdown-labels-error.js-label-error .dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
......
- title = local_assigns.fetch(:title, _('Assign labels')) - title = local_assigns.fetch(:title, _('Assign labels'))
- content_title = local_assigns.fetch(:content_title, _('Create lists from labels. Issues with that label appear in that list.'))
- show_title = local_assigns.fetch(:show_title, true)
- show_create = local_assigns.fetch(:show_create, true) - show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true) - show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false) - show_boards_content = local_assigns.fetch(:show_boards_content, false)
- subject = @project || @group - subject = @project || @group
.dropdown-page-one .dropdown-page-one
= dropdown_title(title) - if show_title
= dropdown_title(title)
- if show_boards_content - if show_boards_content
.issue-board-dropdown-content .issue-board-dropdown-content
%p %p
= _('Create lists from labels. Issues with that label appear in that list.') = content_title
= dropdown_filter(filter_placeholder) = dropdown_filter(filter_placeholder)
= dropdown_content = dropdown_content
- if current_board_parent && show_footer - if current_board_parent && show_footer
......
...@@ -104,14 +104,7 @@ ...@@ -104,14 +104,7 @@
.filter-dropdown-container .filter-dropdown-container
- if type == :boards - if type == :boards
- if can?(current_user, :admin_list, board.parent) - if can?(current_user, :admin_list, board.parent)
.dropdown.prepend-left-10#js-add-list = render_if_exists 'shared/issuable/board_create_list_dropdown', board: board
%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
= 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
- if @project - if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- elsif type != :boards_modal - elsif type != :boards_modal
......
...@@ -150,7 +150,7 @@ describe 'Issue Boards', :js do ...@@ -150,7 +150,7 @@ describe 'Issue Boards', :js do
click_button 'Add list' click_button 'Add list'
wait_for_requests wait_for_requests
find('.dropdown-menu-close').click find('.js-new-board-list').click
page.within(find('.board:nth-child(2)')) do page.within(find('.board:nth-child(2)')) do
accept_confirm { find('.board-delete').click } accept_confirm { find('.board-delete').click }
......
...@@ -143,6 +143,9 @@ describe 'New/edit issue', :js do ...@@ -143,6 +143,9 @@ describe 'New/edit issue', :js do
click_link label.title click_link label.title
click_link label2.title click_link label2.title
end end
find('.js-issuable-form-dropdown.js-label-select').click
page.within '.js-label-select' do page.within '.js-label-select' do
expect(page).to have_content label.title expect(page).to have_content label.title
end end
......
...@@ -29,4 +29,16 @@ describe GroupMembersFinder, '#execute' do ...@@ -29,4 +29,16 @@ describe GroupMembersFinder, '#execute' do
expect(result.to_a).to match_array([member1, member3, member4]) expect(result.to_a).to match_array([member1, member3, member4])
end end
it 'returns members for descendant groups if requested', :nested_groups do
member1 = group.add_master(user2)
member2 = group.add_master(user1)
nested_group.add_master(user2)
member3 = nested_group.add_master(user3)
member4 = nested_group.add_master(user4)
result = described_class.new(group).execute(include_descendants: true)
expect(result.to_a).to match_array([member1, member2, member3, member4])
end
end end
...@@ -19,4 +19,16 @@ describe MembersFinder, '#execute' do ...@@ -19,4 +19,16 @@ describe MembersFinder, '#execute' do
expect(result.to_a).to match_array([member1, member2, member3]) expect(result.to_a).to match_array([member1, member2, member3])
end end
it 'includes nested group members if asked', :nested_groups do
project = create(:project, namespace: group)
nested_group.request_access(user1)
member1 = group.add_master(user2)
member2 = nested_group.add_master(user3)
member3 = project.add_master(user4)
result = described_class.new(project, user2).execute(include_descendants: true)
expect(result.to_a).to match_array([member1, member2, member3])
end
end end
...@@ -37,5 +37,5 @@ ...@@ -37,5 +37,5 @@
"title": { "type": "string" }, "title": { "type": "string" },
"position": { "type": ["integer", "null"] } "position": { "type": ["integer", "null"] }
}, },
"additionalProperties": false "additionalProperties": true
} }
...@@ -5,10 +5,10 @@ ...@@ -5,10 +5,10 @@
import Vue from 'vue'; import Vue from 'vue';
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 '~/boards/models/assignee';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card.vue'; import boardCard from '~/boards/components/board_card.vue';
......
...@@ -7,9 +7,9 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -7,9 +7,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data'; import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
......
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
import Vue from 'vue'; import Vue from 'vue';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import '~/boards/components/issue_card_inner'; import '~/boards/components/issue_card_inner';
import { listObj } from './mock_data'; import { listObj } from './mock_data';
......
...@@ -3,9 +3,9 @@ ...@@ -3,9 +3,9 @@
import Vue from 'vue'; import Vue from 'vue';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import { mockBoardService } from './mock_data'; import { mockBoardService } from './mock_data';
......
...@@ -6,9 +6,9 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -6,9 +6,9 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import _ from 'underscore'; import _ from 'underscore';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service'; import '~/boards/services/board_service';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data'; import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
......
/* global ListIssue */ /* global ListIssue */
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/models/assignee';
import Store from '~/boards/stores/modal_store'; import Store from '~/boards/stores/modal_store';
describe('Modal store', () => { describe('Modal store', () => {
......
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