Commit a300dc4e authored by Mario de la Ossa's avatar Mario de la Ossa Committed by Sean McGivern

Add assignee lists to boards

parent 5e52d9b3
......@@ -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];
......@@ -180,10 +216,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"
......
......@@ -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,
});
......
......@@ -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;
......
......@@ -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';
......@@ -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';
......
/* 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;
......@@ -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.type !== 'promotion' && this.id) {
......@@ -34,14 +39,26 @@ 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 () {
return gl.boardService.createList(this.label.id)
save() {
const entity = this.label || this.assignee;
let entityType = '';
if (this.label) {
entityType = 'label_id';
} else {
entityType = 'assignee_id';
}
return gl.boardService
.createList(entity.id, entityType)
.then(res => res.data)
.then((data) => {
.then(data => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
......@@ -50,25 +67,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;
......@@ -78,7 +93,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) {
......@@ -89,9 +104,10 @@ 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) => {
.then(data => {
this.loading = false;
this.issuesSize = data.size;
......@@ -103,18 +119,21 @@ 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) => {
.then(data => {
issue.id = data.id;
issue.iid = data.iid;
issue.milestone = data.milestone;
issue.project = data.project;
issue.assignees = data.assignees;
issue.assignees = Array.isArray(data.assignees)
? data.assignees.map(assignee => new ListAssignee(assignee))
: data.assignees;
issue.labels = data.labels;
issue.path = data.real_path;
issue.referencePath = data.reference_path;
......@@ -126,13 +145,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;
......@@ -155,6 +174,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;
......@@ -163,29 +189,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) {
......
......@@ -60,11 +60,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,
});
}
......
......@@ -121,8 +121,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);
......@@ -133,7 +140,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);
}
},
......@@ -144,11 +155,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 (replaceState = false) {
if (replaceState) {
......
......@@ -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();
......
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;
module Boards
class ListsController < Boards::ApplicationController
prepend ::EE::Boards::ListsController
include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
......@@ -56,8 +58,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
......@@ -65,11 +71,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
......@@ -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
......
......@@ -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)
......
class List < ActiveRecord::Base
prepend ::EE::List
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?
......
......@@ -5,13 +5,18 @@ module Boards
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
......@@ -22,18 +27,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
......
module Boards
module Issues
class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
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
......@@ -28,10 +30,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?
......
module Boards
module Lists
class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
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 }
......@@ -28,8 +42,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
......
module Boards
module Lists
class ListService < Boards::BaseService
prepend ::EE::Boards::Lists::ListService
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
.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)" }
......@@ -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" },
......
- 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') }
......
- 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_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
- subject = @project || @group
.dropdown-page-one
= dropdown_title(title)
- if show_title
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
%p
= _('Create lists from labels. Issues with that label appear in that list.')
= content_title
= dropdown_filter(filter_placeholder)
= dropdown_content
- if current_board_parent && show_footer
......
......@@ -127,14 +127,7 @@
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list
.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
= 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
= render_if_exists 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn.prepend-left-10
......
......@@ -72,6 +72,8 @@ Rails.application.routes.draw do
end
resources :issues, module: :boards, only: [:index, :update]
resources :users, module: :boards, only: [:index]
end
# UserCallouts
......
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180605213516) do
ActiveRecord::Schema.define(version: 20180607154645) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -1509,10 +1509,12 @@ ActiveRecord::Schema.define(version: 20180605213516) do
t.integer "position"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
end
add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree
add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree
add_index "lists", ["user_id"], name: "index_lists_on_user_id", using: :btree
create_table "members", force: :cascade do |t|
t.integer "access_level", null: false
......@@ -2897,6 +2899,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do
add_foreign_key "lfs_file_locks", "users", on_delete: :cascade
add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade
add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade
add_foreign_key "lists", "users", name: "fk_d6cf4279f7", on_delete: :cascade
add_foreign_key "members", "users", name: "fk_2e88fb7ce9", on_delete: :cascade
add_foreign_key "merge_request_diff_commits", "merge_request_diffs", on_delete: :cascade
add_foreign_key "merge_request_diff_files", "merge_request_diffs", on_delete: :cascade
......
......@@ -40,6 +40,9 @@ organized from a broader perspective with one Issue Board per project,
but also allow your team members to organize their own workflow by creating
multiple Issue Boards within the same project.
[GitLab Premium] adds even more powerful ways to work with Issue Boards by
allowing you to have assignee lists as well as label lists.
## Use cases
You can see below a few different use cases for GitLab's Issue Boards.
......@@ -111,6 +114,10 @@ Cards finished by the UX team will automatically appear in the **Frontend** colu
[Codepen decided to adopt Issue Boards](https://about.gitlab.com/2017/01/27/codepen-welcome-to-gitlab/#project-management-everything-in-one-place)
to improve their workflow with multiple boards.
#### Quick assignments
Create lists for each of your team members and quickly drag-and-drop issues onto each team member.
## Issue Board terminology
Below is a table of the definitions used for GitLab's Issue Board.
......@@ -118,7 +125,8 @@ Below is a table of the definitions used for GitLab's Issue Board.
| What we call it | What it means |
| -------------- | ------------- |
| **Issue Board** | It represents a different view for your issues. It can have multiple lists with each list consisting of issues represented by cards. |
| **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. |
| **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. Each user in the project or group
can also have their own dedicated list. |
| **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. You can re-order cards within a list. |
There are two types of lists, the ones you create based on your labels, and
......@@ -166,7 +174,7 @@ right corner of the Issue Board.
![Issue Board welcome message](img/issue_board_add_list.png)
Simply choose the label to create the list from. The new list will be inserted
Simply choose the label or user to create the list from. The new list will be inserted
at the end of the lists, before **Done**. Moving and reordering lists is as
easy as dragging them around.
......
<script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AssigneesListFilter from './assignees_list_filter.vue';
import AssigneesListContent from './assignees_list_content.vue';
export default {
components: {
LoadingIcon,
AssigneesListFilter,
AssigneesListContent,
},
props: {
loading: {
type: Boolean,
required: true,
},
assignees: {
type: Array,
required: true,
},
},
data() {
return {
query: '',
};
},
computed: {
filteredAssignees() {
if (!this.query) {
return this.assignees;
}
// fuzzaldrinPlus doesn't support filtering
// on multiple keys hence we're using plain JS.
const query = this.query.toLowerCase();
return this.assignees.filter((assignee) => {
const name = assignee.name.toLowerCase();
const username = assignee.username.toLowerCase();
return name.indexOf(query) > -1 || username.indexOf(query) > -1;
});
},
},
methods: {
handleSearch(query) {
this.query = query;
},
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
},
},
};
</script>
<template>
<div class="dropdown-assignees-list">
<div
v-if="loading"
class="dropdown-loading"
>
<loading-icon />
</div>
<assignees-list-filter
@onSearchInput="handleSearch"
/>
<assignees-list-content
v-if="!loading"
:assignees="filteredAssignees"
@onItemSelect="handleItemClick"
/>
</div>
</template>
<script>
import AssigneesListItem from './assignees_list_item.vue';
export default {
components: {
AssigneesListItem,
},
props: {
assignees: {
type: Array,
required: true,
},
},
methods: {
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
},
},
};
</script>
<template>
<div class="dropdown-content">
<ul>
<assignees-list-item
v-for="assignee in assignees"
:key="assignee.id"
:assignee="assignee"
@onItemSelect="handleItemClick"
/>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
query: '',
};
},
methods: {
handleInputChange() {
this.$emit('onSearchInput', this.query);
},
handleInputClear() {
this.query = '';
this.handleInputChange();
},
},
};
</script>
<template>
<div
class="dropdown-input"
:class="{ 'has-value': !!query }"
>
<input
type="search"
class="dropdown-input-field"
:placeholder="__('Search')"
v-model.trim="query"
@keyup="handleInputChange"
/>
<i
class="fa fa-search dropdown-input-search"
aria-hidden="true"
data-hidden="true"
></i>
<i
role="button"
class="fa fa-times dropdown-input-clear"
aria-hidden="true"
data-hidden="true"
@click="handleInputClear"
></i>
</div>
</template>
<script>
import { sprintf, __ } from '~/locale';
export default {
props: {
assignee: {
type: Object,
required: true,
},
},
computed: {
avatarAltText() {
return sprintf(__("%{name}'s avatar"), {
name: this.assignee.name,
});
},
},
methods: {
handleItemClick() {
this.$emit('onItemSelect', this.assignee);
},
},
};
</script>
<template>
<li
class="filter-dropdown-item"
@click="handleItemClick"
>
<button
class="btn btn-link dropdown-user"
type="button"
>
<div class="avatar-container s32">
<img
class="avatar s32 lazy"
:alt="avatarAltText"
:src="assignee.avatar_url"
/>
</div>
<div class="dropdown-user-details">
<div :title="assignee.name">{{ assignee.name }}</div>
<div
class="dropdown-light-content"
:title="assignee.username"
>@{{ assignee.username }}</div>
</div>
</button>
</li>
</template>
import Vue from 'vue';
import _ from 'underscore';
import { __ } from '~/locale';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import AssigneesListContainer from './assignees_list_container.vue';
export default Vue.extend({
components: {
AssigneesListContainer,
},
props: {
listAssigneesPath: {
type: String,
required: true,
},
},
data() {
return {
loading: true,
store: gl.issueBoards.BoardsStore,
};
},
mounted() {
this.loadAssignees();
},
methods: {
loadAssignees() {
if (!this.store.state.assignees.length) {
axios
.get(this.listAssigneesPath)
.then(({ data }) => {
this.loading = false;
this.store.state.assignees = data;
})
.catch(() => {
this.loading = false;
Flash(
__('Something went wrong while fetching assignees list'),
);
});
}
},
handleItemClick(assignee) {
if (!this.store.findList('title', assignee.name)) {
this.store.new({
title: assignee.name,
position: this.store.state.lists.length - 2,
list_type: 'assignee',
user: assignee,
});
this.store.state.lists = _.sortBy(this.store.state.lists, 'position');
}
},
},
render(createElement) {
return createElement('assignees-list-container', {
props: {
loading: this.loading,
assignees: this.store.state.assignees,
},
on: {
onItemSelect: this.handleItemClick,
},
});
},
});
import Vue from 'vue';
import $ from 'jquery';
import { throttle } from 'underscore';
import BoardForm from './board_form.vue';
import AssigneesList from './assignees_list';
(() => {
window.gl = window.gl || {};
......@@ -34,6 +36,7 @@ import BoardForm from './board_form.vue';
open: false,
loading: true,
hasScrollFade: false,
hasAssigneesListMounted: false,
scrollFadeInitialized: false,
boards: [],
state: Store.state,
......@@ -91,9 +94,10 @@ import BoardForm from './board_form.vue';
}
if (this.open && !this.boards.length) {
gl.boardService.allBoards()
gl.boardService
.allBoards()
.then(res => res.data)
.then((json) => {
.then(json => {
this.loading = false;
this.boards = json;
})
......@@ -123,9 +127,32 @@ import BoardForm from './board_form.vue';
this.hasScrollFade = this.isScrolledUp();
},
handleDropdownHide(e) {
const $currTarget = $(e.currentTarget);
if ($currTarget.data('preventClose')) {
e.preventDefault();
}
$currTarget.removeData('preventClose');
},
handleDropdownTabClick(e) {
const $addListEl = $('#js-add-list');
$addListEl.data('preventClose', true);
if (e.target.dataset.action === 'tab-assignees' &&
!this.hasAssigneesListMounted) {
this.assigneeList = new AssigneesList({
propsData: {
listAssigneesPath: $addListEl.find('.js-new-board-list').data('listAssigneesPath'),
},
}).$mount('.js-assignees-list');
this.hasAssigneesListMounted = true;
}
},
},
created() {
this.state.currentBoard = this.currentBoard;
Store.state.assignees = [];
$('#js-add-list').on('hide.bs.dropdown', this.handleDropdownHide);
$('.js-new-board-list-tabs').on('click', this.handleDropdownTabClick);
},
});
})();
......@@ -4,3 +4,108 @@
top: 3px;
}
}
.dropdown.show .dropdown-menu.dropdown-menu-tabs {
max-height: 400px;
overflow-y: hidden;
}
.dropdown .dropdown-menu.dropdown-menu-tabs {
padding-top: 0;
width: 240px;
.dropdown-tabs-list {
display: flex;
box-shadow: 0 0 0 1px $border-color;
.dropdown-tab-item {
flex: 1;
border-left: 1px solid $border-color;
&:first-of-type {
border-left: 0;
}
a {
width: 100%;
padding: $gl-padding $gl-padding-top;
text-align: center;
border-bottom: 2px solid transparent;
background-color: $gray-light;
&:focus,
&.active {
background-color: $white-light;
}
&.active {
font-weight: bold;
border-bottom-color: $indigo-500;
}
}
}
}
.tab-content {
.issue-board-dropdown-content {
margin: 0;
padding: $gl-padding;
border-bottom: 0;
color: $gl-text-color-secondary;
}
.tab-pane-labels {
.dropdown-page-one .dropdown-content {
height: 140px;
}
.dropdown-page-two {
margin-top: 10px;
.dropdown-content {
max-height: initial;
height: 205px;
}
}
}
.tab-pane-assignees {
.dropdown-content {
height: 225px;
max-height: 252px;
}
.dropdown-user {
display: flex;
padding: $gl-padding-8 $gl-padding-24;
}
.dropdown-user-details div {
max-width: 130px;
text-overflow: ellipsis;
overflow: hidden;
}
.dropdown-loading {
display: block;
}
}
}
}
.board-type-assignee {
.board-title-text,
.board-title-sub-text {
@include str-truncated(110px);
}
.board-title-text {
margin-right: 0;
}
.board-title-sub-text {
margin-right: auto;
color: $gl-text-color-secondary;
font-weight: normal;
}
}
module Boards
class UsersController < Boards::ApplicationController
# Enumerates all users that are members of the board parent
# If board parent is a project it only enumerates project members
# If board parent is a group it enumerates all members of current group,
# ancestors, and descendants
def index
user_ids = finder_service
.execute(include_descendants: true)
.non_invite
.select(:user_id)
users = User.where(id: user_ids)
render json: UserSerializer.new.represent(users)
end
private
def finder_service
@service ||=
if board_parent.is_a?(Group)
GroupMembersFinder.new(board_parent)
else
MembersFinder.new(board_parent, current_user)
end
end
end
end
module EE
module Boards
module ListsController
extend ::Gitlab::Utils::Override
override :list_creation_attrs
def list_creation_attrs
super + %i[assignee_id]
end
override :serialization_attrs
def serialization_attrs
super.merge(user: true)
end
end
end
end
module EE
module BoardsHelper
extend ::Gitlab::Utils::Override
def parent
@group || @project
end
override :board_list_data
def board_list_data
super.merge(list_assignees_path: board_users_path(board, :json))
end
override :board_data
def board_data
show_feature_promotion = (@project && show_promotions? &&
(!@project.feature_available?(:multiple_project_issue_boards) ||
......@@ -37,6 +45,7 @@ module EE
)
end
override :boards_link_text
def boards_link_text
if parent.multiple_issue_boards_available?
s_("IssueBoards|Boards")
......
module EE
module List
extend ::Gitlab::Utils::Override
# ActiveSupport::Concern does not prepend the ClassMethods,
# so we cannot call `super` if we use it.
def self.prepended(base)
class << base
prepend ClassMethods
end
base.belongs_to :user
base.validates :user, presence: true, if: :assignee?
base.validates :user_id, uniqueness: { scope: :board_id }, if: :assignee?
base.validates :list_type,
exclusion: { in: %w[assignee], message: _('Assignee boards not available with your current license') },
unless: -> { board&.parent&.feature_available?(:board_assignee_lists) }
end
def assignee=(user)
self.user = user
end
override :destroyable?
def destroyable?
assignee? || super
end
override :movable?
def movable?
assignee? || super
end
override :title
def title
assignee? ? user.to_reference : super
end
override :as_json
def as_json(options = {})
super.tap do |json|
if options.key?(:user)
json[:user] = UserSerializer.new.represent(user).as_json
end
end
end
module ClassMethods
def destroyable_types
super + [:assignee]
end
def movable_types
super + [:assignee]
end
end
end
end
......@@ -36,6 +36,7 @@ class License < ActiveRecord::Base
EEP_FEATURES = EES_FEATURES + %i[
admin_audit_log
auditor_user
board_assignee_lists
cross_project_pipelines
email_additional_text
db_load_balancing
......
......@@ -6,13 +6,14 @@ module EE
override :issue_params
def issue_params
assignee_ids = Array(list.user_id || board.assignee&.id)
{
label_ids: [list.label_id, *board.label_ids],
weight: board.weight,
milestone_id: board.milestone_id,
# This can be removed when boards have multiple assignee support.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
assignee_ids: Array(board.assignee&.id)
assignee_ids: assignee_ids
}
end
end
......
......@@ -2,6 +2,18 @@ module EE
module Boards
module Issues
module ListService
extend ::Gitlab::Utils::Override
override :filter
def filter(issues)
issues = without_board_assignees(issues) unless list&.movable? || list&.closed?
return super unless list&.assignee?
with_assignee(super)
end
override :issues_label_links
def issues_label_links
if has_valid_milestone?
super.where("issues.milestone_id = ?", board.milestone_id)
......@@ -12,6 +24,25 @@ module EE
private
def board_assignee_ids
@board_assignee_ids ||=
if parent.feature_available?(:board_assignee_lists)
board.lists.movable.pluck(:user_id).compact
else
[]
end
end
def without_board_assignees(issues)
return issues unless board_assignee_ids.any?
issues.where.not(id: issues.joins(:assignees).where(users: { id: board_assignee_ids }))
end
def with_assignee(issues)
issues.assigned_to(list.user)
end
# Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone?
......
module EE
module Boards
module Issues
module MoveService
extend ::Gitlab::Utils::Override
override :issue_params
def issue_params(issue)
return super unless move_between_lists?
args = super
unless both_are_same_type? || !moving_to_list.movable?
args.delete(:remove_label_ids)
end
args.merge(assignee_ids: assignee_ids(issue))
end
def both_are_list_type?(type)
return false unless moving_from_list.list_type == type
both_are_same_type?
end
def both_are_same_type?
moving_from_list.list_type == moving_to_list.list_type
end
def assignee_ids(issue)
assignees = (issue.assignee_ids + [moving_to_list.user_id]).compact
assignees -= [moving_from_list.user_id] if both_are_list_type?('assignee') || moving_to_list.backlog?
assignees
end
end
end
end
end
module EE
module Boards
module Lists
module CreateService
extend ::Gitlab::Utils::Override
override :type
def type
return :assignee if params.keys.include?('assignee_id')
super
end
override :target
def target(board)
strong_memoize(:target) do
case type
when :assignee
find_user(board)
else
super
end
end
end
def find_user(board)
user_ids = user_finder(board).execute(include_descendants: true).non_invite.select(:user_id)
::User.where(id: user_ids).find(params[:assignee_id])
end
def user_finder(board)
@service ||=
if board.parent.is_a?(Group)
GroupMembersFinder.new(board.parent)
else
MembersFinder.new(board.parent, current_user)
end
end
end
end
end
end
module EE
module Boards
module Lists
module ListService
extend ::Gitlab::Utils::Override
override :execute
def execute(board)
return super if board.parent.feature_available?(:board_assignee_lists)
super.where.not(list_type: ::List.list_types[:assignee])
end
end
end
end
end
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
.dropdown-page-one
.issue-board-dropdown-content
%p
= _('Assignee lists show all issues assigned to the selected user.')
.js-assignees-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 }
%span Add list
= sprite_icon('chevron-down', size: 16, css_class: 'prepend-left-5 btn-new-board-list-chevron')
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.dropdown-menu-tabs
%ul.nav.nav-tabs.dropdown-tabs-list.js-new-board-list-tabs{ role: 'tablist' }
%li.nav-item.dropdown-tab-item.js-tab-button-labels
%a.active{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-labels', target: '#tab-labels' } }
Label list
%li.nav-item.dropdown-tab-item.js-tab-button-labels
%a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-assignees', target: '#tab-assignees' } }
Assignee list
.tab-content
#tab-labels.tab-pane.tab-pane-labels.active.js-tab-container-labels{ role: 'tabpanel' }
= render partial: "shared/issuable/label_page_default", locals: { show_title: false, show_footer: true, show_create: true, show_boards_content: true, content_title: _('Label lists show all issues with the selected label.') }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create", locals: { show_close: false }
#tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' }
= render partial: "shared/issuable/assignee_page_default"
= dropdown_loading
---
title: Add assignee board list type
merge_request: 5743
author:
type: added
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :lists, :user_id, :integer
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserIndexToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_concurrent_index :lists, :user_id
end
def down
remove_concurrent_index :lists, :user_id
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserFkToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_concurrent_foreign_key :lists, :users, column: :user_id, on_delete: :cascade
end
def down
remove_foreign_key :lists, column: :user_id
end
end
......@@ -26,7 +26,7 @@ describe Boards::ListsController do
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists')
expect(response).to match_response_schema('lists', dir: 'ee')
expect(parsed_response.length).to eq 3
end
......@@ -62,7 +62,7 @@ describe Boards::ListsController do
it 'returns the created list' do
create_board_list user: user, board: board, label_id: label.id
expect(response).to match_response_schema('list')
expect(response).to match_response_schema('list', dir: 'ee')
end
end
......
require 'spec_helper'
describe Boards::UsersController do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:guest) { create(:user) }
let(:user) { create(:user) }
before do
group.add_master(user)
group.add_guest(guest)
sign_in(user)
end
describe 'GET index' do
it 'returns a list of all members of board parent' do
get :index, namespace_id: group.to_param,
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/user'))
expect(parsed_response.length).to eq 2
end
end
end
FactoryBot.define do
factory :user_list, parent: :list do
list_type :assignee
label nil
user
end
end
{
"type": "object",
"allOf": [
{
"$ref": "../../../../../spec/fixtures/api/schemas/list.json"
},
{
"required": ["user"],
"properties": {
"user": {
"type": [
"object",
"null"
],
"required": [
"id",
"name",
"username",
"state",
"avatar_url",
"web_url",
"path"
],
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"state": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
"web_url": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
}
}
]
}
\ No newline at end of file
{
"type": "array",
"items": {
"$ref": "list.json"
}
}
\ No newline at end of file
require 'spec_helper'
describe BoardsHelper do
describe '#board_list_data' do
let(:results) { helper.board_list_data }
it 'contains an endpoint to get users list' do
project = create(:project)
board = create(:board, project: project)
assign(:board, board)
assign(:project, project)
expect(results).to include(list_assignees_path: "/-/boards/#{board.id}/users.json")
end
end
end
require 'rails_helper'
describe List do
context 'when it is an assignee type' do
let(:board) { create(:board) }
subject { described_class.new(list_type: :assignee, board: board) }
it { is_expected.to be_destroyable }
it { is_expected.to be_movable }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:user) }
end
describe '#title' do
it 'returns the username as title' do
subject.user = create(:user, username: 'some_user')
expect(subject.title).to eq('@some_user')
end
end
end
end
require 'spec_helper'
describe Boards::Issues::CreateService do
describe '#execute' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:user) { create(:user) }
let(:label) { create(:label, project: project, name: 'in-progress') }
let(:list) { create(:list, board: board, user: user, list_type: List.list_types[:assignee], position: 0) }
subject(:service) { described_class.new(board.parent, project, user, board_id: board.id, list_id: list.id, title: 'New issue') }
before do
stub_licensed_features(board_assignee_lists: true)
project.add_developer(user)
end
it 'assigns the issue to the List assignee' do
issue = service.execute
expect(issue.assignees).to eq([user])
end
end
end
......@@ -19,35 +19,39 @@ describe Boards::Issues::ListService, services: true do
let(:p2) { create(:group_label, title: 'P2', group: group) }
let(:p3) { create(:group_label, title: 'P3', group: group) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
let!(:closed) { create(:closed_list, board: board) }
let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
let!(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Issue 3', closed_at: Time.now ) }
let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
let!(:list1_issue3) { create(:labeled_issue, project: project1, milestone: m1, labels: [development, p1]) }
let!(:list2_issue1) { create(:labeled_issue, project: project1, milestone: m1, labels: [testing]) }
let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
let!(:closed_issue3) { create(:issue, :closed, project: project1) }
let!(:closed_issue4) { create(:labeled_issue, :closed, project: project1, labels: [p1]) }
let!(:closed_issue5) { create(:labeled_issue, :closed, project: project1, labels: [development]) }
let(:user_list) { create(:user_list, board: board, position: 2) }
let(:backlog) { create(:backlog_list, board: board) }
let(:list1) { create(:list, board: board, label: development, position: 0) }
let(:list2) { create(:list, board: board, label: testing, position: 1) }
let(:closed) { create(:closed_list, board: board) }
let(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
let(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
let(:opened_issue3) { create(:labeled_issue, project: project, milestone: m2, title: 'Assigned Issue', labels: [p3]) }
let(:reopened_issue1) { create(:issue, state: 'opened', project: project, title: 'Issue 3', closed_at: Time.now ) }
let(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
let(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
let(:list1_issue3) { create(:labeled_issue, project: project1, milestone: m1, labels: [development, p1]) }
let(:list2_issue1) { create(:labeled_issue, project: project1, milestone: m1, labels: [testing]) }
let(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) }
let(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) }
let(:closed_issue3) { create(:issue, :closed, project: project1) }
let(:closed_issue4) { create(:labeled_issue, :closed, project: project1, labels: [p1]) }
let(:closed_issue5) { create(:labeled_issue, :closed, project: project1, labels: [development]) }
let(:parent) { group }
before do
stub_licensed_features(board_assignee_lists: true)
parent.add_developer(user)
opened_issue3.assignees.push(user_list.user)
end
context 'when list_id is missing' do
context 'when board does not have a milestone' do
it 'returns opened issues without board labels applied' do
it 'returns opened issues without board labels and assignees applied' do
params = { board_id: board.id }
issues = described_class.new(parent, user, params).execute
......@@ -57,7 +61,7 @@ describe Boards::Issues::ListService, services: true do
end
context 'when board have a milestone' do
it 'returns opened issues without board labels and milestone applied' do
it 'returns opened issues without board labels, assignees, or milestone applied' do
params = { board_id: board.id }
board.update_attribute(:milestone, m1)
......@@ -74,7 +78,7 @@ describe Boards::Issues::ListService, services: true do
board.update(milestone_id: Milestone::Upcoming.id)
end
it 'returns open issue for backlog without board label' do
it 'returns open issue for backlog without board label or assignees' do
issues = described_class.new(parent, user, params).execute
expect(issues).to match_array([opened_issue2, reopened_issue1, opened_issue1])
......@@ -86,7 +90,7 @@ describe Boards::Issues::ListService, services: true do
board.update(milestone_id: Milestone::Started.id)
end
it 'returns open issue for backlog without board label' do
it 'returns open issue for backlog without board label or assignees' do
issues = described_class.new(parent, user, params).execute
expect(issues).to match_array([opened_issue2, reopened_issue1, opened_issue1])
......
require 'spec_helper'
describe Boards::Issues::MoveService, services: true do
shared_examples 'moving an issue to/from assignee lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } }
context 'from assignee to label list' do
it 'does not unassign and adds label' do
params = { board_id: board1.id, from_list_id: list3.id, to_list_id: list2.id }
issue.assignees.push(list3.user)
expect(issue.assignees).to contain_exactly(list3.user)
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, development, testing)
expect(issue.assignees).to contain_exactly(list3.user)
end
end
context 'from assignee to backlog' do
it 'removes assignment' do
params = { board_id: board1.id, from_list_id: list3.id, to_list_id: backlog.id }
issue.assignees.push(list3.user)
expect(issue.assignees).to contain_exactly(list3.user)
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.assignees).to eq([])
expect(issue).not_to be_closed
end
end
context 'from assignee to closed list' do
it 'keeps assignment and closes the issue' do
params = { board_id: board1.id, from_list_id: list3.id, to_list_id: closed.id }
issue.assignees.push(list3.user)
expect(issue.assignees).to contain_exactly(list3.user)
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.assignees).to contain_exactly(list3.user)
expect(issue).to be_closed
end
end
context 'from label list to assignee' do
it 'assigns and does not remove label' do
params = { board_id: board1.id, from_list_id: list1.id, to_list_id: list3.id }
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, development)
expect(issue.assignees).to contain_exactly(list3.user)
end
end
context 'between two assignee lists' do
it 'unassigns removal and assigns addition' do
params = { board_id: board1.id, from_list_id: list3.id, to_list_id: list4.id }
issue.assignees.push(list3.user)
expect(issue.assignees).to contain_exactly(list3.user)
described_class.new(parent, user, params).execute(issue)
issue.reload
expect(issue.labels).to contain_exactly(bug, development)
expect(issue.assignees).to contain_exactly(user)
end
end
end
describe '#execute' do
let(:user) { create(:user) }
let!(:board1) { create(:board, **parent_attr) }
let(:board2) { create(:board, **parent_attr) }
let(:list1) { create(:list, board: board1, label: development, position: 0) }
let(:list2) { create(:list, board: board1, label: testing, position: 1) }
let(:list3) { create(:user_list, board: board1, position: 2) }
let(:list4) { create(:user_list, board: board1, user: user, position: 3) }
let(:closed) { create(:closed_list, board: board1) }
let(:backlog) { create(:backlog_list, board: board1) }
context 'when parent is a project' do
let(:project) { create(:project) }
let(:parent_attr) { { project: project } }
let(:parent) { project }
let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') }
let(:testing) { create(:label, project: project, name: 'Testing') }
let(:regression) { create(:label, project: project, name: 'Regression') }
before do
stub_licensed_features(board_assignee_lists: true)
parent.add_developer(user)
parent.add_developer(list3.user)
end
it_behaves_like 'moving an issue to/from assignee lists'
end
context 'when parent is a group' do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:parent_attr) { { group: group } }
let(:parent) { group }
let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
let(:testing) { create(:group_label, group: group, name: 'Testing') }
let(:regression) { create(:group_label, group: group, name: 'Regression') }
before do
stub_licensed_features(board_assignee_lists: true)
parent.add_developer(user)
parent.add_developer(list3.user)
end
it_behaves_like 'moving an issue to/from assignee lists'
end
end
end
require 'spec_helper'
describe Boards::Lists::ListService do
describe '#execute' do
shared_examples 'list service for board with assignee lists' do
let!(:assignee_list) { build(:user_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(true)
end
it 'returns all lists' do
expect(service.execute(board)).to match_array [backlog_list, list, assignee_list, board.closed_list]
end
end
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
end
end
end
context 'when board parent is a project' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
let(:label) { create(:label, project: project) }
let(:service) { described_class.new(project, double) }
it_behaves_like 'list service for board with assignee lists'
end
context 'when board parent is a group' do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:label) { create(:group_label, group: group) }
let(:service) { described_class.new(group, double) }
it_behaves_like 'list service for board with assignee lists'
end
end
end
......@@ -152,7 +152,7 @@ describe 'Issue Boards', :js do
click_button 'Add list'
wait_for_requests
find('.dropdown-menu-close').click
find('.js-new-board-list').click
page.within(find('.board:nth-child(2)')) do
accept_confirm { find('.board-delete').click }
......
......@@ -144,9 +144,10 @@ describe 'New/edit issue', :js do
page.within '.dropdown-menu-labels' do
click_link label.title
click_link label2.title
find('.dropdown-menu-close').click
end
find('.js-issuable-form-dropdown.js-label-select').click
page.within '.js-label-select' do
expect(page).to have_content label.title
end
......
......@@ -29,4 +29,16 @@ describe GroupMembersFinder, '#execute' do
expect(result.to_a).to match_array([member1, member3, member4])
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
......@@ -19,4 +19,16 @@ describe MembersFinder, '#execute' do
expect(result.to_a).to match_array([member1, member2, member3])
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
......@@ -37,5 +37,5 @@
"title": { "type": "string" },
"position": { "type": ["integer", "null"] }
},
"additionalProperties": false
"additionalProperties": true
}
......@@ -5,10 +5,10 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import '~/boards/models/assignee';
import eventHub from '~/boards/eventhub';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/list';
import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card.vue';
......
......@@ -7,9 +7,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
import '~/boards/stores/boards_store';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
......
import Vue from 'vue';
import AssigneesListContainerComponent from 'ee/boards/components/assignees_list/assignees_list_container.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../mock_data';
const createComponent = () => {
const Component = Vue.extend(AssigneesListContainerComponent);
return mountComponent(Component, {
loading: false,
assignees: mockAssigneesList,
});
};
describe('AssigneesListContainerComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('filteredAssignees', () => {
it('returns assignees list as it is when `query` is empty', () => {
vm.query = '';
expect(vm.filteredAssignees.length).toBe(mockAssigneesList.length);
});
it('returns filtered assignees list as it is when `query` has name', () => {
const assignee = mockAssigneesList[0];
vm.query = assignee.name;
expect(vm.filteredAssignees.length).toBe(1);
expect(vm.filteredAssignees[0].name).toBe(assignee.name);
});
it('returns filtered assignees list as it is when `query` has username', () => {
const assignee = mockAssigneesList[0];
vm.query = assignee.username;
expect(vm.filteredAssignees.length).toBe(1);
expect(vm.filteredAssignees[0].username).toBe(assignee.username);
});
});
});
describe('methods', () => {
describe('handleSearch', () => {
it('sets value of param `query` to component prop `query`', () => {
const query = 'foobar';
vm.handleSearch(query);
expect(vm.query).toBe(query);
});
});
describe('handleItemClick', () => {
it('emits `onItemSelect` event on component and sends `assignee` as event param', () => {
spyOn(vm, '$emit');
const assignee = mockAssigneesList[0];
vm.handleItemClick(assignee);
expect(vm.$emit).toHaveBeenCalledWith('onItemSelect', assignee);
});
});
});
describe('template', () => {
it('renders component container element with class `dropdown-assignees-list`', () => {
expect(vm.$el.classList.contains('dropdown-assignees-list')).toBe(true);
});
it('renders loading animation when prop `loading` is true', (done) => {
vm.loading = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.dropdown-loading')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
it('renders dropdown body elements', () => {
expect(vm.$el.querySelector('.dropdown-input')).not.toBeNull();
expect(vm.$el.querySelector('.dropdown-content')).not.toBeNull();
});
});
});
import Vue from 'vue';
import AssigneesListContentComponent from 'ee/boards/components/assignees_list/assignees_list_content.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../mock_data';
const createComponent = () => {
const Component = Vue.extend(AssigneesListContentComponent);
return mountComponent(Component, {
assignees: mockAssigneesList,
});
};
describe('AssigneesListContentComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('handleItemClick', () => {
it('emits `onItemSelect` event on component and sends `assignee` as event param', () => {
spyOn(vm, '$emit');
const assignee = mockAssigneesList[0];
vm.handleItemClick(assignee);
expect(vm.$emit).toHaveBeenCalledWith('onItemSelect', assignee);
});
});
});
describe('template', () => {
it('renders component container element with class `dropdown-content`', () => {
expect(vm.$el.classList.contains('dropdown-content')).toBe(true);
});
it('renders UL parent element as child within container', () => {
expect(vm.$el.querySelector('ul')).not.toBeNull();
});
});
});
import Vue from 'vue';
import AssigneesListFilterComponent from 'ee/boards/components/assignees_list/assignees_list_filter.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(AssigneesListFilterComponent);
return mountComponent(Component);
};
describe('AssigneesListFilterComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('handleInputChange', () => {
it('emits `onSearchInput` event on component and sends `query` as event param', () => {
spyOn(vm, '$emit');
const query = 'foobar';
vm.query = query;
vm.handleInputChange();
expect(vm.$emit).toHaveBeenCalledWith('onSearchInput', query);
});
});
describe('handleInputClear', () => {
it('clears value of prop `query` and calls `handleInputChange` method on component', () => {
spyOn(vm, 'handleInputChange');
vm.query = 'foobar';
vm.handleInputClear();
expect(vm.query).toBe('');
expect(vm.handleInputChange).toHaveBeenCalled();
});
});
});
describe('template', () => {
it('renders component container element with class `dropdown-input`', () => {
expect(vm.$el.classList.contains('dropdown-input')).toBe(true);
});
it('renders class `has-value` on container element when prop `query` is not empty', (done) => {
vm.query = 'foobar';
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('has-value')).toBe(true);
})
.then(done)
.catch(done.fail);
});
it('removes class `has-value` from container element when prop `query` is empty', (done) => {
vm.query = '';
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('has-value')).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('renders search input element', () => {
const inputEl = vm.$el.querySelector('input.dropdown-input-field');
expect(inputEl).not.toBeNull();
expect(inputEl.getAttribute('placeholder')).toBe('Search');
});
it('renders search input icons', () => {
expect(vm.$el.querySelector('i.fa.fa-search.dropdown-input-search')).not.toBeNull();
expect(vm.$el.querySelector('i.fa.fa-times.dropdown-input-clear')).not.toBeNull();
});
});
});
import Vue from 'vue';
import AssigneesListItemComponent from 'ee/boards/components/assignees_list/assignees_list_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../mock_data';
const createComponent = () => {
const Component = Vue.extend(AssigneesListItemComponent);
return mountComponent(Component, {
assignee: mockAssigneesList[0],
});
};
describe('AssigneesListItemComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('avatarAltText', () => {
it('returns computed alt text based on assignee.name', () => {
expect(vm.avatarAltText).toBe(`${mockAssigneesList[0].name}'s avatar`);
});
});
});
describe('methods', () => {
describe('handleItemClick', () => {
it('emits `onItemSelect` event on component and sends `assignee` as event param', () => {
spyOn(vm, '$emit');
const assignee = mockAssigneesList[0];
vm.handleItemClick();
expect(vm.$emit).toHaveBeenCalledWith('onItemSelect', assignee);
});
});
});
describe('template', () => {
it('renders component container element with class `filter-dropdown-item`', () => {
expect(vm.$el.classList.contains('filter-dropdown-item')).toBe(true);
});
it('renders user item button element', () => {
const assignee = mockAssigneesList[0];
const buttonEl = vm.$el.querySelector('.dropdown-user');
expect(buttonEl).not.toBeNull();
expect(
buttonEl.querySelector('.avatar-container.s32 img.avatar.s32').getAttribute('src'),
).toBe(assignee.avatar_url);
expect(buttonEl.querySelector('.dropdown-user-details').innerText).toContain(assignee.name);
expect(
buttonEl.querySelector('.dropdown-user-details .dropdown-light-content').innerText,
).toContain(`@${assignee.username}`);
});
});
});
import '~/boards/stores/boards_store';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import AssigneesListComponent from 'ee/boards/components/assignees_list/';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../mock_data';
const createComponent = () => mountComponent(AssigneesListComponent, {
listAssigneesPath: `${gl.TEST_HOST}/users.json`,
});
describe('AssigneesListComponent', () => {
let vm;
let mock;
let statusCode;
let response;
gl.issueBoards.BoardsStore.create();
gl.issueBoards.BoardsStore.state.assignees = [];
beforeEach(() => {
statusCode = 200;
response = mockAssigneesList;
mock = new MockAdapter(axios);
document.body.innerHTML += '<div class="flash-container"></div>';
mock.onGet(`${gl.TEST_HOST}/users.json`).reply(() => [statusCode, response]);
vm = createComponent();
});
afterEach(() => {
document.querySelector('.flash-container').remove();
vm.$destroy();
mock.restore();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.loading).toBe(true);
expect(vm.store).toBe(gl.issueBoards.BoardsStore);
});
});
describe('methods', () => {
describe('loadAssignees', () => {
it('calls axios.get and sets response to store.state.assignees', (done) => {
mock.onGet(`${gl.TEST_HOST}/users.json`).reply(200, mockAssigneesList);
gl.issueBoards.BoardsStore.state.assignees = [];
vm.loadAssignees();
setTimeout(() => {
expect(vm.loading).toBe(false);
expect(vm.store.state.assignees.length).toBe(mockAssigneesList.length);
done();
}, 0);
});
it('does not call axios.get when store.state.assignees is not empty', () => {
spyOn(axios, 'get');
gl.issueBoards.BoardsStore.state.assignees = mockAssigneesList;
vm.loadAssignees();
expect(axios.get).not.toHaveBeenCalled();
});
it('calls axios.get and shows Flash error when request fails', (done) => {
mock.onGet(`${gl.TEST_HOST}/users.json`).reply(500, {});
gl.issueBoards.BoardsStore.state.assignees = [];
vm.loadAssignees();
setTimeout(() => {
expect(vm.loading).toBe(false);
expect(document.querySelector('.flash-text').innerText.trim()).toBe('Something went wrong while fetching assignees list');
done();
}, 0);
});
});
describe('handleItemClick', () => {
it('creates new list in a store instance', () => {
spyOn(vm.store, 'new');
const assignee = mockAssigneesList[0];
expect(vm.store.findList('title', assignee.name)).not.toBeDefined();
vm.handleItemClick(assignee);
expect(vm.store.new).toHaveBeenCalledWith(jasmine.any(Object));
});
});
});
});
......@@ -6,9 +6,9 @@ import Vue from 'vue';
import _ from 'underscore';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/stores/boards_store';
import '~/boards/components/issue_card_inner';
import { listObj } from './mock_data';
......
......@@ -3,9 +3,9 @@
import Vue from 'vue';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
import '~/boards/stores/boards_store';
import { mockBoardService } from './mock_data';
......
......@@ -6,9 +6,9 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import _ from 'underscore';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
import '~/boards/stores/boards_store';
import { listObj, listObjDuplicate, boardsMockInterceptor, mockBoardService } from './mock_data';
......
......@@ -82,3 +82,51 @@ export const mockBoardService = (opts = {}) => {
boardId,
});
};
export const mockAssigneesList = [
{
id: 2,
name: 'Terrell Graham',
username: 'monserrate.gleichner',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/598fd02741ac58b88854a99d16704309?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/monserrate.gleichner',
path: '/monserrate.gleichner',
},
{
id: 12,
name: 'Susy Johnson',
username: 'tana_harvey',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e021a7b0f3e4ae53b5068d487e68c031?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/tana_harvey',
path: '/tana_harvey',
},
{
id: 20,
name: 'Conchita Eichmann',
username: 'juliana_gulgowski',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/c43c506cb6fd7b37017d3b54b94aa937?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/juliana_gulgowski',
path: '/juliana_gulgowski',
},
{
id: 6,
name: 'Bryce Turcotte',
username: 'melynda',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/cc2518f2c6f19f8fac49e1a5ee092a9b?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/melynda',
path: '/melynda',
},
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
web_url: 'http://127.0.0.1:3001/root',
path: '/root',
},
];
/* global ListIssue */
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import '~/boards/models/assignee';
import Store from '~/boards/stores/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