Commit 5db71fed authored by Phil Hughes's avatar Phil Hughes

Merge branch '3772-total-weight' into 'master'

View summed weights of issues in board column

Closes #3772

See merge request gitlab-org/gitlab-ee!6218
parents f171f6a4 2678c0c6
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import Vue from 'vue'; import Vue from 'vue';
import boardPromotionState from 'ee/boards/components/board_promotion_state'; import { n__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list.vue'; import boardList from './board_list.vue';
import BoardBlankState from './board_blank_state.vue'; import BoardBlankState from './board_blank_state.vue';
...@@ -18,7 +20,10 @@ gl.issueBoards.Board = Vue.extend({ ...@@ -18,7 +20,10 @@ gl.issueBoards.Board = Vue.extend({
boardList, boardList,
'board-delete': gl.issueBoards.BoardDelete, 'board-delete': gl.issueBoards.BoardDelete,
BoardBlankState, BoardBlankState,
boardPromotionState, Icon,
},
directives: {
Tooltip,
}, },
props: { props: {
list: { list: {
...@@ -48,6 +53,12 @@ gl.issueBoards.Board = Vue.extend({ ...@@ -48,6 +53,12 @@ gl.issueBoards.Board = Vue.extend({
filter: Store.filter, filter: Store.filter,
}; };
}, },
computed: {
counterTooltip() {
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
},
watch: { watch: {
filter: { filter: {
handler() { handler() {
......
...@@ -39,13 +39,13 @@ ...@@ -39,13 +39,13 @@
Vue.http.patch(this.updateUrl, data).catch(() => { Vue.http.patch(this.updateUrl, data).catch(() => {
Flash(__('Failed to remove issue from board, please try again.')); Flash(__('Failed to remove issue from board, please try again.'));
lists.forEach(list => { lists.forEach((list) => {
list.addIssue(issue); list.addIssue(issue);
}); });
}); });
// Remove from the frontend store // Remove from the frontend store
lists.forEach(list => { lists.forEach((list) => {
list.removeIssue(issue); list.removeIssue(issue);
}); });
......
...@@ -28,6 +28,7 @@ import 'ee/boards/models/issue'; // eslint-disable-line import/first ...@@ -28,6 +28,7 @@ import 'ee/boards/models/issue'; // eslint-disable-line import/first
import 'ee/boards/models/project'; // eslint-disable-line import/first import 'ee/boards/models/project'; // eslint-disable-line import/first
import BoardService from 'ee/boards/services/board_service'; // eslint-disable-line import/first import BoardService from 'ee/boards/services/board_service'; // eslint-disable-line import/first
import 'ee/boards/components/board_sidebar'; // eslint-disable-line import/first import 'ee/boards/components/board_sidebar'; // eslint-disable-line import/first
import 'ee/boards/components/board'; // eslint-disable-line import/first
import 'ee/boards/components/modal/index'; // eslint-disable-line import/first import 'ee/boards/components/modal/index'; // eslint-disable-line import/first
import 'ee/boards/components/boards_selector'; // eslint-disable-line import/first import 'ee/boards/components/boards_selector'; // eslint-disable-line import/first
import collapseIcon from 'ee/boards/icons/fullscreen_collapse.svg'; // eslint-disable-line import/first import collapseIcon from 'ee/boards/icons/fullscreen_collapse.svg'; // eslint-disable-line import/first
...@@ -89,14 +90,12 @@ export default () => { ...@@ -89,14 +90,12 @@ export default () => {
eventHub.$on('newDetailIssue', this.updateDetailIssue); eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue); eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$on('updateWeight', this.updateWeight);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens); eventHub.$off('updateTokens', this.updateTokens);
eventHub.$off('newDetailIssue', this.updateDetailIssue); eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue); eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$off('updateWeight', this.updateWeight);
}, },
mounted() { mounted() {
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit); this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
...@@ -178,24 +177,6 @@ export default () => { ...@@ -178,24 +177,6 @@ export default () => {
}); });
} }
}, },
updateWeight(newWeight, id) {
const { issue } = Store.detail;
if (issue.id === id && issue.sidebarInfoEndpoint) {
issue.setLoadingState('weight', true);
BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight)
.then(res => res.data)
.then(data => {
issue.setLoadingState('weight', false);
issue.updateData({
weight: data.weight,
});
})
.catch(() => {
issue.setLoadingState('weight', false);
Flash(__('An error occurred when updating the issue weight'));
});
}
},
}, },
}); });
......
...@@ -136,6 +136,8 @@ class List { ...@@ -136,6 +136,8 @@ class List {
} }
this.createIssues(data.issues); this.createIssues(data.issues);
return data;
}); });
} }
......
...@@ -143,11 +143,17 @@ gl.issueBoards.BoardsStore = { ...@@ -143,11 +143,17 @@ gl.issueBoards.BoardsStore = {
} else if (listTo.type === 'backlog' && listFrom.type === 'assignee') { } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee); issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} else if ((listTo.type !== 'label' && listFrom.type === 'assignee') || } else if (this.shouldRemoveIssue(listFrom, listTo)) {
(listTo.type !== 'assignee' && listFrom.type === 'label')) {
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} }
}, },
shouldRemoveIssue(listFrom, listTo) {
return (
(listTo.type !== 'label' && listFrom.type === 'assignee') ||
(listTo.type !== 'assignee' && listFrom.type === 'label') ||
(listFrom.type === 'backlog')
);
},
moveIssueInList (list, issue, oldIndex, newIndex, idArray) { moveIssueInList (list, issue, oldIndex, newIndex, idArray) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null; const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + 1], 10) || null; const afterId = parseInt(idArray[newIndex + 1], 10) || null;
......
...@@ -440,3 +440,5 @@ textarea { ...@@ -440,3 +440,5 @@ textarea {
color: $placeholder-text-color; color: $placeholder-text-color;
} }
} }
.lh-100 { line-height: 1; }
...@@ -242,7 +242,7 @@ ...@@ -242,7 +242,7 @@
.board-title { .board-title {
margin: 0; margin: 0;
padding: 12px $gl-padding; padding: $gl-padding-8 $gl-padding;
font-size: 1em; font-size: 1em;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
display: flex; display: flex;
......
.issue-count-badge { .issue-count-badge {
display: inline-flex; display: inline-flex;
align-items: stretch;
height: 24px;
}
.issue-count-badge-count {
display: flex;
align-items: center;
padding-right: 10px;
padding-left: 10px;
border: 1px solid $border-color;
border-radius: $border-radius-base; border-radius: $border-radius-base;
line-height: 1; border: 1px solid $border-color;
padding: 5px $gl-padding-8;
&.has-btn {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
} }
.issue-count-badge-add-button { .issue-count-badge-count {
display: flex; display: inline-flex;
align-items: center; align-items: center;
border: 1px solid $border-color;
border-radius: 0 $border-radius-base $border-radius-base 0;
line-height: 1;
} }
...@@ -12,8 +12,9 @@ module Boards ...@@ -12,8 +12,9 @@ module Boards
skip_before_action :authenticate_user!, only: [:index] skip_before_action :authenticate_user!, only: [:index]
def index def index
issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues.page(params[:page]).per(params[:per] || 20) issues = list_service.execute
issues = issues.page(params[:page]).per(params[:per] || 20).without_count
make_sure_position_is_set(issues) if Gitlab::Database.read_write? make_sure_position_is_set(issues) if Gitlab::Database.read_write?
issues = issues.preload(:project, issues = issues.preload(:project,
:milestone, :milestone,
...@@ -22,10 +23,7 @@ module Boards ...@@ -22,10 +23,7 @@ module Boards
notes: [:award_emoji, :author] notes: [:award_emoji, :author]
) )
render json: { render_issues(issues, list_service.metadata)
issues: serialize_as_json(issues),
size: issues.total_count
}
end end
def create def create
...@@ -51,6 +49,13 @@ module Boards ...@@ -51,6 +49,13 @@ module Boards
private private
def render_issues(issues, metadata)
data = { issues: serialize_as_json(issues) }
data.merge!(metadata)
render json: data
end
def make_sure_position_is_set(issues) def make_sure_position_is_set(issues)
issues.each do |issue| issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position issue.move_to_end && issue.save unless issue.relative_position
......
...@@ -4,15 +4,35 @@ module Boards ...@@ -4,15 +4,35 @@ module Boards
module Issues module Issues
class ListService < Boards::BaseService class ListService < Boards::BaseService
prepend EE::Boards::Issues::ListService prepend EE::Boards::Issues::ListService
include Gitlab::Utils::StrongMemoize
def execute def execute
issues = IssuesFinder.new(current_user, filter_params).execute fetch_issues.order_by_position_and_priority
issues = filter(issues) end
issues.order_by_position_and_priority
def metadata
keys = metadata_fields.keys
columns = metadata_fields.values_at(*keys).join(', ')
results = Issue.where(id: fetch_issues.select('issues.id')).pluck(columns)
Hash[keys.zip(results.flatten)]
end end
private private
def metadata_fields
{ size: 'COUNT(*)' }
end
# We memoize the query here since the finder methods we use are quite complex. This does not memoize the result of the query.
def fetch_issues
strong_memoize(:fetch_issues) do
issues = IssuesFinder.new(current_user, filter_params).execute
filter(issues).reorder(nil)
end
end
def filter(issues) def filter(issues)
issues = without_board_labels(issues) unless list&.movable? || list&.closed? issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label? issues = with_list_label(issues) if list&.label?
......
...@@ -32,17 +32,21 @@ ...@@ -32,17 +32,21 @@
"v-if" => "!list.preset && list.id" } "v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } %button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash") = icon("trash")
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' } .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
%span.issue-count-badge-count.float-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' } %span.issue-count-badge-count
%icon.mr-1{ name: "issues" }
{{ list.issuesSize }} {{ list.issuesSize }}
- if can?(current_user, :admin_list, current_board_parent)
%button.issue-count-badge-add-button.btn.btn-sm.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button", = render_if_exists "shared/boards/components/list_weight"
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"', - if can?(current_user, :admin_list, current_board_parent)
"aria-label" => _("New issue"), %button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button",
"title" => _("New issue"), "@click" => "showNewIssueForm",
data: { placement: "top", container: "body" } } "v-if" => 'list.type !== "closed"',
= icon("plus", class: "js-no-trigger-collapse") "aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse")
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list", ":list" => "list",
":issues" => "list.issues", ":issues" => "list.issues",
......
import '~/boards/components/board';
import { __, n__, sprintf } from '~/locale';
import boardPromotionState from 'ee/boards/components/board_promotion_state';
const base = gl.issueBoards.Board;
gl.issueBoards.Board = base.extend({
components: {
boardPromotionState,
},
computed: {
counterTooltip() {
const { issuesSize, totalWeight } = this.list;
return sprintf(__(
`${n__('%d issue', '%d issues', issuesSize)} with %{totalWeight} total weight`),
{
totalWeight,
},
);
},
},
});
...@@ -11,10 +11,41 @@ const EE_TYPES = { ...@@ -11,10 +11,41 @@ const EE_TYPES = {
}; };
class ListEE extends List { class ListEE extends List {
constructor(...args) {
super(...args);
this.totalWeight = 0;
}
getTypeInfo(type) { getTypeInfo(type) {
return EE_TYPES[type] || super.getTypeInfo(type); return EE_TYPES[type] || super.getTypeInfo(type);
} }
getIssues(...args) {
return super.getIssues(...args).then(data => {
this.totalWeight = data.total_weight;
});
}
addIssue(issue, ...args) {
super.addIssue(issue, ...args);
if (issue.weight) {
this.totalWeight += issue.weight;
}
}
removeIssue(issue, ...args) {
if (issue.weight) {
this.totalWeight -= issue.weight;
}
super.removeIssue(issue, ...args);
}
addWeight(weight) {
this.totalWeight += weight;
}
onNewIssueResponse(issue, data) { onNewIssueResponse(issue, data) {
issue.milestone = data.milestone; issue.milestone = data.milestone;
issue.assignees = Array.isArray(data.assignees) issue.assignees = Array.isArray(data.assignees)
......
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __ } from '~/locale';
import BoardService from 'ee/boards/services/board_service';
import sidebarEventHub from '~/sidebar/event_hub';
import createFlash from '~/flash';
class BoardsStoreEE { class BoardsStoreEE {
initEESpecific(boardsStore) { initEESpecific(boardsStore) {
...@@ -36,6 +40,8 @@ class BoardsStoreEE { ...@@ -36,6 +40,8 @@ class BoardsStoreEE {
window.history.pushState(null, null, `?${this.store.filter.path}`); window.history.pushState(null, null, `?${this.store.filter.path}`);
} }
}; };
sidebarEventHub.$on('updateWeight', this.updateWeight.bind(this));
} }
initBoardFilters() { initBoardFilters() {
...@@ -126,6 +132,32 @@ class BoardsStoreEE { ...@@ -126,6 +132,32 @@ class BoardsStoreEE {
promotionIsHidden() { promotionIsHidden() {
return Cookies.get('promotion_issue_board_hidden') === 'true'; return Cookies.get('promotion_issue_board_hidden') === 'true';
} }
updateWeight(newWeight, id) {
const { issue } = this.store.detail;
if (issue.id === id && issue.sidebarInfoEndpoint) {
issue.setLoadingState('weight', true);
BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight)
.then(res => res.data)
.then(data => {
const lists = issue.getLists();
const oldWeight = issue.weight;
const weightDiff = newWeight - oldWeight;
issue.setLoadingState('weight', false);
issue.updateData({
weight: data.weight,
});
lists.forEach(list => {
list.addWeight(weightDiff);
});
})
.catch(() => {
issue.setLoadingState('weight', false);
createFlash(__('An error occurred when updating the issue weight'));
});
}
}
} }
export default new BoardsStoreEE(); export default new BoardsStoreEE();
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import sortableConfig from 'ee/sortable/sortable_config'; import sortableConfig from 'ee/sortable/sortable_config';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueItem from './issue_item.vue'; import issueItem from './issue_item.vue';
...@@ -13,6 +14,7 @@ export default { ...@@ -13,6 +14,7 @@ export default {
tooltip, tooltip,
}, },
components: { components: {
Icon,
loadingIcon, loadingIcon,
addIssuableForm, addIssuableForm,
issueItem, issueItem,
...@@ -159,16 +161,21 @@ fa fa-question-circle" ...@@ -159,16 +161,21 @@ fa fa-question-circle"
aria-label="Read more about related issues"> aria-label="Read more about related issues">
</i> </i>
</a> </a>
<div <div class="d-inline-flex lh-100 align-middle">
class="js-related-issues-header-issue-count <div
related-issues-header-issue-count issue-count-badge" class="js-related-issues-header-issue-count
> related-issues-header-issue-count issue-count-badge mx-1"
<span
:class="{ 'has-btn': canAdmin }"
class="issue-count-badge-count"
> >
{{ badgeLabel }} <span
</span> class="issue-count-badge-count"
>
<icon
name="issues"
class="mr-1 text-secondary"
/>
{{ badgeLabel }}
</span>
</div>
<button <button
v-if="canAdmin" v-if="canAdmin"
ref="issueCountBadgeAddButton" ref="issueCountBadgeAddButton"
......
...@@ -24,6 +24,11 @@ module EE ...@@ -24,6 +24,11 @@ module EE
private private
override :metadata_fields
def metadata_fields
super.merge(total_weight: 'COALESCE(SUM(weight), 0)')
end
def board_assignee_ids def board_assignee_ids
@board_assignee_ids ||= @board_assignee_ids ||=
if parent.feature_available?(:board_assignee_lists) if parent.feature_available?(:board_assignee_lists)
......
%span.d-inline-flex.ml-2
%icon.mr-1{ name: "scale" }
{{ list.totalWeight }}
---
title: Summed issue weights in board columns
merge_request: 6218
author:
type: added
...@@ -46,7 +46,7 @@ describe Boards::IssuesController do ...@@ -46,7 +46,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues') expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2 expect(parsed_response['issues'].length).to eq 2
expect(development.issues.map(&:relative_position)).not_to include(nil) expect(development.issues.map(&:relative_position)).not_to include(nil)
end end
end end
...@@ -73,7 +73,7 @@ describe Boards::IssuesController do ...@@ -73,7 +73,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues') expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2 expect(parsed_response['issues'].length).to eq 2
end end
end end
......
require 'spec_helper' require 'spec_helper'
describe 'issue boards', :js do describe 'issue boards', :js do
include DragTo
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
let!(:board) { create(:board, project: project) } let!(:board) { create(:board, project: project) }
...@@ -91,6 +93,43 @@ describe 'issue boards', :js do ...@@ -91,6 +93,43 @@ describe 'issue boards', :js do
end end
end end
context 'total weight' do
let!(:label) { create(:label, project: project, name: 'Label 1') }
let!(:list) { create(:list, board: board, label: label, position: 0) }
let!(:issue) { create(:issue, project: project, weight: 3) }
let!(:issue_2) { create(:issue, project: project, weight: 2) }
before do
project.add_developer(user)
login_as(user)
visit_board_page
end
it 'shows total weight for backlog' do
backlog = board.lists.first
expect(badge(backlog)).to have_content('5')
end
it 'updates weight when moving to list' do
from = board.lists.first
to = list
drag_to(selector: '.board-list',
scrollable: '#board-app',
list_from_index: 0,
from_index: 0,
to_index: 0,
list_to_index: 1)
expect(badge(from)).to have_content('3')
expect(badge(to)).to have_content('2')
end
end
def badge(list)
find(".board[data-id='#{list.id}'] .issue-count-badge")
end
def visit_board_page def visit_board_page
visit project_boards_path(project) visit project_boards_path(project)
wait_for_requests wait_for_requests
......
...@@ -25,10 +25,10 @@ describe Boards::Issues::ListService, services: true do ...@@ -25,10 +25,10 @@ describe Boards::Issues::ListService, services: true do
let(:list2) { create(:list, board: board, label: testing, position: 1) } let(:list2) { create(:list, board: board, label: testing, position: 1) }
let(:closed) { create(:closed_list, board: board) } let(:closed) { create(:closed_list, board: board) }
let(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) } let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, weight: 9, title: 'Issue 1', labels: [bug]) }
let(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) } let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, weight: 1, title: 'Issue 2', labels: [p2]) }
let(:opened_issue3) { create(:labeled_issue, project: project, milestone: m2, title: 'Assigned Issue', labels: [p3]) } 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!(: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_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_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
...@@ -49,6 +49,28 @@ describe Boards::Issues::ListService, services: true do ...@@ -49,6 +49,28 @@ describe Boards::Issues::ListService, services: true do
opened_issue3.assignees.push(user_list.user) opened_issue3.assignees.push(user_list.user)
end end
context '#metadata' do
it 'returns issues count and weight for list' do
params = { board_id: board.id, id: backlog.id }
metadata = described_class.new(parent, user, params).metadata
expect(metadata[:size]).to eq(3)
expect(metadata[:total_weight]).to eq(10)
end
# When collection is filtered by labels the ActiveRecord::Relation returns '{}' on #count or #sum
# if no issues are found
it 'returns 0 when filtering by labels and issues are not present' do
params = { board_id: board.id, id: list1.id, label_name: [bug.title, p2.title] }
metadata = described_class.new(parent, user, params).metadata
expect(metadata[:size]).to eq(0)
expect(metadata[:total_weight]).to eq(0)
end
end
context 'when list_id is missing' do context 'when list_id is missing' do
context 'when board does not have a milestone' do context 'when board does not have a milestone' do
it 'returns opened issues without board labels and assignees applied' do it 'returns opened issues without board labels and assignees applied' do
......
...@@ -42,7 +42,7 @@ describe Boards::IssuesController do ...@@ -42,7 +42,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues') expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2 expect(parsed_response['issues'].length).to eq 2
expect(development.issues.map(&:relative_position)).not_to include(nil) expect(development.issues.map(&:relative_position)).not_to include(nil)
end end
...@@ -80,7 +80,7 @@ describe Boards::IssuesController do ...@@ -80,7 +80,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues') expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2 expect(parsed_response['issues'].length).to eq 2
end end
end end
......
...@@ -2,14 +2,16 @@ ...@@ -2,14 +2,16 @@
"type": "object", "type": "object",
"required" : [ "required" : [
"issues", "issues",
"size" "size",
"total_weight"
], ],
"properties" : { "properties" : {
"issues": { "issues": {
"type": "array", "type": "array",
"items": { "$ref": "issue.json" } "items": { "$ref": "issue.json" }
}, },
"size": { "type": "integer" } "size": { "type": "integer" },
"total_weight": { "type": "integer" }
}, },
"additionalProperties": false "additionalProperties": false
} }
...@@ -161,6 +161,28 @@ describe('Store', () => { ...@@ -161,6 +161,28 @@ describe('Store', () => {
}, 0); }, 0);
}); });
it('moves an issue from backlog to a list', (done) => {
const backlog = gl.issueBoards.BoardsStore.addList({
...listObj,
list_type: 'backlog',
});
const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2);
setTimeout(() => {
expect(backlog.issues.length).toBe(1);
expect(listTwo.issues.length).toBe(1);
gl.issueBoards.BoardsStore.moveIssueToList(backlog, listTwo, backlog.findIssue(1));
expect(backlog.issues.length).toBe(0);
expect(listTwo.issues.length).toBe(1);
done();
}, 0);
});
it('moves issue to top of another list', (done) => { it('moves issue to top of another list', (done) => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj); const listOne = gl.issueBoards.BoardsStore.addList(listObj);
const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate);
......
import { listObj, mockBoardService } from 'spec/boards/mock_data';
import CeList from '~/boards/models/list';
import List from 'ee/boards/models/list';
import Issue from 'ee/boards/models/issue';
describe('List model', () => {
let list;
let issue;
beforeEach(() => {
gl.boardService = mockBoardService();
list = new List(listObj);
issue = new Issue({
title: 'Testing',
id: 2,
iid: 2,
labels: [],
assignees: [],
weight: 5,
});
});
afterEach(() => {
list = null;
issue = null;
});
it('inits totalWeight', () => {
expect(list.totalWeight).toBe(0);
});
describe('getIssues', () => {
it('calls CE getIssues', (done) => {
const ceGetIssues = spyOn(CeList.prototype, 'getIssues').and.returnValue(Promise.resolve({}));
list.getIssues().then(() => {
expect(ceGetIssues).toHaveBeenCalled();
done();
}).catch(done.fail);
});
it('sets total weight', (done) => {
spyOn(CeList.prototype, 'getIssues').and.returnValue(Promise.resolve({
total_weight: 11,
}));
list.getIssues().then(() => {
expect(list.totalWeight).toBe(11);
done();
}).catch(done.fail);
});
});
describe('addIssue', () => {
it('updates totalWeight', () => {
list.addIssue(issue);
expect(list.totalWeight).toBe(5);
});
it('calls CE addIssue with all args', () => {
const ceAddIssue = spyOn(CeList.prototype, 'addIssue');
list.addIssue(issue, list, 2);
expect(ceAddIssue).toHaveBeenCalledWith(issue, list, 2);
});
});
describe('removeIssue', () => {
beforeEach(() => {
list.addIssue(issue);
});
it('updates totalWeight', () => {
list.removeIssue(issue);
expect(list.totalWeight).toBe(0);
});
it('calls CE removeIssue', () => {
const ceRemoveIssue = spyOn(CeList.prototype, 'removeIssue');
list.removeIssue(issue);
expect(ceRemoveIssue).toHaveBeenCalledWith(issue);
});
});
});
...@@ -7,6 +7,16 @@ shared_examples 'issues list service' do ...@@ -7,6 +7,16 @@ shared_examples 'issues list service' do
described_class.new(parent, user, params).execute described_class.new(parent, user, params).execute
end end
context '#metadata' do
it 'returns issues count for list' do
params = { board_id: board.id, id: list1.id }
metadata = described_class.new(parent, user, params).metadata
expect(metadata[:size]).to eq(3)
end
end
context 'issues are ordered by priority' do context 'issues are ordered by priority' do
it 'returns opened issues when list_id is missing' do it 'returns opened issues when list_id is missing' do
params = { board_id: board.id } params = { board_id: board.id }
......
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