Commit 2678c0c6 authored by Simon Knox's avatar Simon Knox Committed by Phil Hughes

View summed weights of issues in board column

parent f171f6a4
......@@ -2,7 +2,9 @@
import Sortable from 'sortablejs';
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 boardList from './board_list.vue';
import BoardBlankState from './board_blank_state.vue';
......@@ -18,7 +20,10 @@ gl.issueBoards.Board = Vue.extend({
boardList,
'board-delete': gl.issueBoards.BoardDelete,
BoardBlankState,
boardPromotionState,
Icon,
},
directives: {
Tooltip,
},
props: {
list: {
......@@ -48,6 +53,12 @@ gl.issueBoards.Board = Vue.extend({
filter: Store.filter,
};
},
computed: {
counterTooltip() {
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
},
watch: {
filter: {
handler() {
......
......@@ -39,13 +39,13 @@
Vue.http.patch(this.updateUrl, data).catch(() => {
Flash(__('Failed to remove issue from board, please try again.'));
lists.forEach(list => {
lists.forEach((list) => {
list.addIssue(issue);
});
});
// Remove from the frontend store
lists.forEach(list => {
lists.forEach((list) => {
list.removeIssue(issue);
});
......
......@@ -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 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'; // 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 collapseIcon from 'ee/boards/icons/fullscreen_collapse.svg'; // eslint-disable-line import/first
......@@ -89,14 +90,12 @@ export default () => {
eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$on('updateWeight', this.updateWeight);
},
beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens);
eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$off('updateWeight', this.updateWeight);
},
mounted() {
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
......@@ -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 {
}
this.createIssues(data.issues);
return data;
});
}
......
......@@ -143,11 +143,17 @@ gl.issueBoards.BoardsStore = {
} 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')) {
} else if (this.shouldRemoveIssue(listFrom, listTo)) {
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) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + 1], 10) || null;
......
......@@ -440,3 +440,5 @@ textarea {
color: $placeholder-text-color;
}
}
.lh-100 { line-height: 1; }
......@@ -242,7 +242,7 @@
.board-title {
margin: 0;
padding: 12px $gl-padding;
padding: $gl-padding-8 $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
display: flex;
......
.issue-count-badge {
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;
line-height: 1;
&.has-btn {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
border: 1px solid $border-color;
padding: 5px $gl-padding-8;
}
.issue-count-badge-add-button {
display: flex;
.issue-count-badge-count {
display: inline-flex;
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
skip_before_action :authenticate_user!, only: [:index]
def index
issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute
issues = issues.page(params[:page]).per(params[:per] || 20)
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
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?
issues = issues.preload(:project,
:milestone,
......@@ -22,10 +23,7 @@ module Boards
notes: [:award_emoji, :author]
)
render json: {
issues: serialize_as_json(issues),
size: issues.total_count
}
render_issues(issues, list_service.metadata)
end
def create
......@@ -51,6 +49,13 @@ module Boards
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)
issues.each do |issue|
issue.move_to_end && issue.save unless issue.relative_position
......
......@@ -4,15 +4,35 @@ module Boards
module Issues
class ListService < Boards::BaseService
prepend EE::Boards::Issues::ListService
include Gitlab::Utils::StrongMemoize
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = filter(issues)
issues.order_by_position_and_priority
fetch_issues.order_by_position_and_priority
end
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
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)
issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label?
......
......@@ -32,17 +32,21 @@
"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" }
= icon("trash")
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
%span.issue-count-badge-count.float-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
.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
%icon.mr-1{ name: "issues" }
{{ 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",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
"aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse")
= render_if_exists "shared/boards/components/list_weight"
- if can?(current_user, :admin_list, current_board_parent)
%button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => 'list.type !== "closed"',
"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"',
":list" => "list",
":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 = {
};
class ListEE extends List {
constructor(...args) {
super(...args);
this.totalWeight = 0;
}
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) {
issue.milestone = data.milestone;
issue.assignees = Array.isArray(data.assignees)
......
/* eslint-disable class-methods-use-this */
import _ from 'underscore';
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 {
initEESpecific(boardsStore) {
......@@ -36,6 +40,8 @@ class BoardsStoreEE {
window.history.pushState(null, null, `?${this.store.filter.path}`);
}
};
sidebarEventHub.$on('updateWeight', this.updateWeight.bind(this));
}
initBoardFilters() {
......@@ -126,6 +132,32 @@ class BoardsStoreEE {
promotionIsHidden() {
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();
......@@ -2,6 +2,7 @@
import Sortable from 'sortablejs';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import sortableConfig from 'ee/sortable/sortable_config';
import eventHub from '../event_hub';
import issueItem from './issue_item.vue';
......@@ -13,6 +14,7 @@ export default {
tooltip,
},
components: {
Icon,
loadingIcon,
addIssuableForm,
issueItem,
......@@ -159,16 +161,21 @@ fa fa-question-circle"
aria-label="Read more about related issues">
</i>
</a>
<div
class="js-related-issues-header-issue-count
related-issues-header-issue-count issue-count-badge"
>
<span
:class="{ 'has-btn': canAdmin }"
class="issue-count-badge-count"
<div class="d-inline-flex lh-100 align-middle">
<div
class="js-related-issues-header-issue-count
related-issues-header-issue-count issue-count-badge mx-1"
>
{{ badgeLabel }}
</span>
<span
class="issue-count-badge-count"
>
<icon
name="issues"
class="mr-1 text-secondary"
/>
{{ badgeLabel }}
</span>
</div>
<button
v-if="canAdmin"
ref="issueCountBadgeAddButton"
......
......@@ -24,6 +24,11 @@ module EE
private
override :metadata_fields
def metadata_fields
super.merge(total_weight: 'COALESCE(SUM(weight), 0)')
end
def board_assignee_ids
@board_assignee_ids ||=
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
parsed_response = JSON.parse(response.body)
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)
end
end
......@@ -73,7 +73,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2
expect(parsed_response['issues'].length).to eq 2
end
end
......
require 'spec_helper'
describe 'issue boards', :js do
include DragTo
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let!(:board) { create(:board, project: project) }
......@@ -91,6 +93,43 @@ describe 'issue boards', :js do
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
visit project_boards_path(project)
wait_for_requests
......
......@@ -25,10 +25,10 @@ describe Boards::Issues::ListService, services: true do
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!(: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, weight: 1, 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]) }
......@@ -49,6 +49,28 @@ describe Boards::Issues::ListService, services: true do
opened_issue3.assignees.push(user_list.user)
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 board does not have a milestone' do
it 'returns opened issues without board labels and assignees applied' do
......
......@@ -42,7 +42,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body)
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)
end
......@@ -80,7 +80,7 @@ describe Boards::IssuesController do
parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('issues')
expect(parsed_response.length).to eq 2
expect(parsed_response['issues'].length).to eq 2
end
end
......
......@@ -2,14 +2,16 @@
"type": "object",
"required" : [
"issues",
"size"
"size",
"total_weight"
],
"properties" : {
"issues": {
"type": "array",
"items": { "$ref": "issue.json" }
},
"size": { "type": "integer" }
"size": { "type": "integer" },
"total_weight": { "type": "integer" }
},
"additionalProperties": false
}
......@@ -161,6 +161,28 @@ describe('Store', () => {
}, 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) => {
const listOne = gl.issueBoards.BoardsStore.addList(listObj);
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
described_class.new(parent, user, params).execute
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
it 'returns opened issues when list_id is missing' do
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