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 {
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];
......@@ -179,10 +215,11 @@ export default {
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
class="board-list"
class="board-list js-board-list"
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }">
<board-card
v-for="(issue, index) in issues"
......
......@@ -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,
});
......@@ -141,4 +142,3 @@ export default {
</div>
</div>
</template>
......@@ -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.id) {
......@@ -34,14 +39,25 @@ class List {
}
guid() {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
const s4 = () =>
Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
}
save () {
save() {
const entity = this.label || this.assignee;
let entityType = '';
if (this.label) {
entityType = 'label_id';
} else {
entityType = 'assignee_id';
}
return gl.boardService.createList(this.label.id)
.then(res => res.data)
.then((data) => {
.then(data => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
......@@ -50,25 +66,23 @@ class List {
});
}
destroy () {
destroy() {
const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this);
gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
gl.boardService.destroyList(this.id)
.catch(() => {
gl.boardService.destroyList(this.id).catch(() => {
// TODO: handle request error
});
}
update () {
gl.boardService.updateList(this.id, this.position)
.catch(() => {
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 +92,7 @@ class List {
}
}
getIssues (emptyIssues = true) {
getIssues(emptyIssues = true) {
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
if (this.label && data.label_name) {
......@@ -89,7 +103,8 @@ class List {
this.loading = true;
}
return gl.boardService.getIssuesForList(this.id, data)
return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data)
.then((data) => {
this.loading = false;
......@@ -103,11 +118,12 @@ class List {
});
}
newIssue (issue) {
newIssue(issue) {
this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data)
.then((data) => {
issue.id = data.id;
......@@ -123,13 +139,13 @@ class List {
});
}
createIssues (data) {
data.forEach((issueObj) => {
createIssues(data) {
data.forEach(issueObj => {
this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
addIssue (issue, listFrom, newIndex) {
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
......@@ -152,6 +168,13 @@ class List {
issue.addLabel(this.label);
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (listFrom) {
this.issuesSize += 1;
......@@ -160,29 +183,29 @@ class List {
}
}
moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
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) {
......
......@@ -30,11 +30,13 @@ export default class BoardService {
return axios.post(this.listsEndpointGenerate, {});
}
createList(labelId) {
createList(entityId, entityType) {
const list = {
[entityType]: entityId,
};
return axios.post(this.listsEndpoint, {
list: {
label_id: labelId,
},
list,
});
}
......
......@@ -103,8 +103,15 @@ gl.issueBoards.BoardsStore = {
const listLabels = issueLists.map(listIssue => listIssue.label);
if (!issueTo) {
// 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);
......@@ -115,7 +122,11 @@ gl.issueBoards.BoardsStore = {
list.removeIssue(issue);
});
issue.removeLabels(listLabels);
} else {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue);
} else if ((listTo.type !== 'label' && listFrom.type === 'assignee') ||
(listTo.type !== 'assignee' && listFrom.type === 'label')) {
listFrom.removeIssue(issue);
}
},
......@@ -126,11 +137,12 @@ gl.issueBoards.BoardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
findList (key, val, type = 'label') {
return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true;
const filteredList = this.state.lists.filter((list) => {
const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
return list[key] === val && byType;
})[0];
});
return filteredList[0];
},
updateFiltersUrl () {
history.pushState(null, null, `?${this.filter.path}`);
......
......@@ -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;
......@@ -56,8 +56,12 @@ module Boards
private
def list_creation_attrs
%i[label_id]
end
def list_params
params.require(:list).permit(:label_id)
params.require(:list).permit(list_creation_attrs)
end
def move_params
......@@ -65,11 +69,15 @@ module Boards
end
def serialize_as_json(resource)
resource.as_json(
resource.as_json(serialization_attrs)
end
def serialization_attrs
{
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
}
end
end
end
......@@ -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
wheres << "members.id IN (#{group_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 (#{group_members.select(:id).to_sql})"]
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)
......
......@@ -2,17 +2,27 @@ class List < ActiveRecord::Base
belongs_to :board
belongs_to :label
enum list_type: { backlog: 0, label: 1, closed: 2 }
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label?
validates :label_id, uniqueness: { scope: :board_id }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :movable?
before_destroy :can_be_destroyed
scope :destroyable, -> { where(list_type: list_types[:label]) }
scope :movable, -> { where(list_type: list_types[:label]) }
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
class << self
def destroyable_types
[:label]
end
def movable_types
[:label]
end
end
def destroyable?
label?
......
......@@ -3,13 +3,18 @@ module Boards
class ListService < Boards::BaseService
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
issues = with_list_label(issues) if movable_list?
issues = filter(issues)
issues.order_by_position_and_priority
end
private
def filter(issues)
issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label?
issues
end
def board
@board ||= parent.boards.find(params[:board_id])
end
......@@ -20,18 +25,6 @@ module Boards
@list = board.lists.find(params[:id]) if params.key?(:id)
end
def movable_list?
return @movable_list if defined?(@movable_list)
@movable_list = list.present? && list.movable?
end
def closed_list?
return @closed_list if defined?(@closed_list)
@closed_list = list.present? && list.closed?
end
def filter_params
set_parent
set_state
......
......@@ -3,7 +3,7 @@ module Boards
class MoveService < Boards::BaseService
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty?
return false if issue_params(issue).empty?
update(issue)
end
......@@ -28,10 +28,10 @@ module Boards
end
def update(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params(issue)).execute(issue)
end
def issue_params
def issue_params(issue)
attrs = {}
if move_between_lists?
......
module Boards
module Lists
class CreateService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def execute(board)
List.transaction do
label = available_labels_for(board).find(params[:label_id])
target = target(board)
position = next_position(board)
create_list(board, label, position)
create_list(board, type, target, position)
end
end
private
def type
:label
end
def target(board)
strong_memoize(:target) do
available_labels_for(board).find(params[:label_id])
end
end
def available_labels_for(board)
options = { include_ancestor_groups: true }
......@@ -28,8 +40,8 @@ module Boards
max_position.nil? ? 0 : max_position.succ
end
def create_list(board, label, position)
board.lists.create(label: label, list_type: :label, position: position)
def create_list(board, type, target, position)
board.lists.create(type => target, list_type: type, position: position)
end
end
end
......
.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" },
......
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- show_close = local_assigns.fetch(:show_close, true)
- subject = @project || @group
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true })
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
= dropdown_content do
.dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
......
- 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
- 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
......
......@@ -104,14 +104,7 @@
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, board.parent)
.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) } }
- elsif type != :boards_modal
......
......@@ -150,7 +150,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 }
......
......@@ -143,6 +143,9 @@ describe 'New/edit issue', :js do
click_link label.title
click_link label2.title
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';
......
......@@ -5,9 +5,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/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';
......
/* 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