Commit 99090899 authored by Wei-Meng Lee's avatar Wei-Meng Lee Committed by Bob Van Landuyt

Render the burndown chart on the frontend

This adds an events API to the milestone API endpoints. These
endpoints are used by the burndown charts so they can be rendered on
the frontend.

Rendering on the frontend makes sure that the viewer's time zone is
taking into account.
parent 7479e1eb
......@@ -136,3 +136,18 @@ Parameters:
- `milestone_id` (required) - The ID of a group milestone
[ce-12819]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12819
## Get all burndown chart events for a single milestone **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4737) in GitLab 12.1
Get all burndown chart events for a single milestone.
```
GET /groups/:id/milestones/:milestone_id/burndown_events
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a group milestone
......@@ -147,3 +147,18 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a project milestone
## Get all burndown chart events for a single milestone **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4737) in GitLab 12.1
Gets all burndown chart events for a single milestone.
```
GET /projects/:id/milestones/:milestone_id/burndown_events
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `milestone_id` (required) - The ID of a project milestone
import dateFormat from 'dateformat';
export default class BurndownChartData {
constructor(burndownEvents, startDate, dueDate) {
this.dateFormatMask = 'yyyy-mm-dd';
this.burndownEvents = this.convertEventsToLocalTimezone(burndownEvents);
this.startDate = startDate;
this.dueDate = dueDate;
// determine when to stop burndown chart
const today = dateFormat(new Date(), this.dateFormatMask);
this.endDate = today < this.dueDate ? today : this.dueDate;
}
generate() {
let openIssuesCount = 0;
let openIssuesWeight = 0;
const chartData = [];
for (
let date = new Date(this.startDate);
date <= new Date(this.endDate);
date.setDate(date.getDate() + 1)
) {
const dateString = dateFormat(date, this.dateFormatMask);
const openedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
(event.action === 'created' || event.action === 'reopened'),
);
const closedIssuesToday = this.filterAndSummarizeBurndownEvents(
event => event.created_at === dateString && event.action === 'closed',
);
openIssuesCount += openedIssuesToday.count - closedIssuesToday.count;
openIssuesWeight += openedIssuesToday.weight - closedIssuesToday.weight;
chartData.push([dateString, openIssuesCount, openIssuesWeight]);
}
return chartData;
}
convertEventsToLocalTimezone(events) {
return events.map(event => ({
...event,
created_at: dateFormat(event.created_at, this.dateFormatMask),
}));
}
filterAndSummarizeBurndownEvents(filter) {
const issues = this.burndownEvents.filter(filter);
return {
count: issues.length,
weight: issues.reduce((total, issue) => total + issue.weight, 0),
};
}
}
import $ from 'jquery';
import Cookies from 'js-cookie';
import BurndownChart from './burndown_chart';
import BurndownChartData from './burndown_chart_data';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
export default () => {
......@@ -13,49 +16,61 @@ export default () => {
// generate burndown chart (if data available)
const container = '.burndown-chart';
const $chartElm = $(container);
if ($chartElm.length) {
const startDate = $chartElm.data('startDate');
const dueDate = $chartElm.data('dueDate');
const chartData = $chartElm.data('chartData');
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
const chart = new BurndownChart({ container, startDate, dueDate });
let currentView = 'count';
chart.setData(openIssuesCount, { label: s__('BurndownChartLabel|Open issues'), animate: true });
$('.js-burndown-data-selector').on('click', 'button', function switchData() {
const $this = $(this);
const show = $this.data('show');
if (currentView !== show) {
currentView = show;
$this
.removeClass('btn-inverted')
.siblings()
.addClass('btn-inverted');
switch (show) {
case 'count':
chart.setData(openIssuesCount, {
label: s__('BurndownChartLabel|Open issues'),
animate: true,
});
break;
case 'weight':
chart.setData(openIssuesWeight, {
label: s__('BurndownChartLabel|Open issue weight'),
animate: true,
});
break;
default:
break;
}
}
});
window.addEventListener('resize', () => chart.animateResize(1));
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2));
const $chartEl = $(container);
if ($chartEl.length) {
const startDate = $chartEl.data('startDate');
const dueDate = $chartEl.data('dueDate');
const burndownEventsPath = $chartEl.data('burndownEventsPath');
axios
.get(burndownEventsPath)
.then(response => {
const burndownEvents = response.data;
const chartData = new BurndownChartData(burndownEvents, startDate, dueDate).generate();
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
const chart = new BurndownChart({ container, startDate, dueDate });
let currentView = 'count';
chart.setData(openIssuesCount, {
label: s__('BurndownChartLabel|Open issues'),
animate: true,
});
$('.js-burndown-data-selector').on('click', 'button', function switchData() {
const $this = $(this);
const show = $this.data('show');
if (currentView !== show) {
currentView = show;
$this
.removeClass('btn-inverted')
.siblings()
.addClass('btn-inverted');
switch (show) {
case 'count':
chart.setData(openIssuesCount, {
label: s__('BurndownChartLabel|Open issues'),
animate: true,
});
break;
case 'weight':
chart.setData(openIssuesWeight, {
label: s__('BurndownChartLabel|Open issue weight'),
animate: true,
});
break;
default:
break;
}
}
});
window.addEventListener('resize', () => chart.animateResize(1));
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2));
})
.catch(() => new Flash('Error loading burndown chart data'));
}
};
......@@ -3,23 +3,8 @@
class Burndown
include Gitlab::Utils::StrongMemoize
class Issue
attr_reader :closed_at, :weight, :state
def initialize(closed_at, weight, state)
@closed_at = closed_at
@weight = weight
@state = state
end
def reopened?
@state == 'opened' && @closed_at.present?
end
end
attr_reader :start_date, :due_date, :end_date, :accurate, :legacy_data, :milestone, :current_user
attr_reader :start_date, :due_date, :end_date, :accurate, :milestone, :current_user
alias_method :accurate?, :accurate
alias_method :empty?, :legacy_data
def initialize(milestone, current_user)
@milestone = milestone
......@@ -29,132 +14,97 @@ class Burndown
@end_date = @milestone.due_date
@end_date = Date.today if @end_date.present? && @end_date > Date.today
@accurate = milestone_issues.all?(&:closed_at)
@legacy_data = milestone_issues.any? && milestone_issues.none?(&:closed_at)
@accurate = true
end
# Returns the chart data in the following format:
# [date, issue count, issue weight] eg: [["2017-03-01", 33, 127], ["2017-03-02", 35, 73], ["2017-03-03", 28, 50]...]
# Returns an array of milestone issue event data in the following format:
# [{"created_at":"2019-03-10T16:00:00.039Z", "weight":null, "action":"closed" }, ... ]
def as_json(opts = nil)
return [] unless valid?
open_issues_count = 0
open_issues_weight = 0
start_date.upto(end_date).each_with_object([]) do |date, chart_data|
closed, reopened = closed_and_reopened_issues_by(date)
closed_issues_count = closed.count
closed_issues_weight = sum_issues_weight(closed)
issues_created = opened_issues_on(date)
open_issues_count += issues_created.count
open_issues_weight += sum_issues_weight(issues_created)
open_issues_count -= closed_issues_count
open_issues_weight -= closed_issues_weight
chart_data << [date.strftime("%Y-%m-%d"), open_issues_count, open_issues_weight]
reopened_count = reopened.count
reopened_weight = sum_issues_weight(reopened)
burndown_events
end
open_issues_count += reopened_count
open_issues_weight += reopened_weight
end
def empty?
burndown_events.any? && legacy_data?
end
def valid?
start_date && due_date
end
# If all closed issues have no closed events, mark burndown chart as containing legacy data
def legacy_data?
strong_memoize(:legacy_data) do
closed_events = milestone_issues.select(&:closed?)
closed_events.any? && !Event.closed.where(target: closed_events, action: Event::CLOSED).exists?
end
end
private
def opened_issues_on(date)
return {} if opened_issues_grouped_by_date.empty?
def burndown_events
milestone_issues
.map { |issue| burndown_events_for(issue) }
.flatten
end
date = date.to_date
def burndown_events_for(issue)
[
transformed_create_event_for(issue),
transformed_action_events_for(issue),
transformed_legacy_closed_event_for(issue)
].compact
end
def milestone_issues
return [] unless valid?
# If issues.created_at < milestone.start_date
# we consider all of them created at milestone.start_date
if date == start_date
first_issue_created_at = opened_issues_grouped_by_date.keys.first
days_before_start_date = start_date.downto(first_issue_created_at).to_a
opened_issues_grouped_by_date.values_at(*days_before_start_date).flatten.compact
else
opened_issues_grouped_by_date[date] || []
strong_memoize(:milestone_issues) do
@milestone
.issues_visible_to_user(current_user)
.where('issues.created_at <= ?', end_date.end_of_day)
end
end
def opened_issues_grouped_by_date
strong_memoize(:opened_issues_grouped_by_date) do
issues =
@milestone
.issues_visible_to_user(current_user)
.where('issues.created_at <= ?', end_date)
.reorder(nil)
.order('issues.created_at').to_a
issues.group_by do |issue|
issue.created_at.to_date
end
def milestone_events_per_issue
return [] unless valid?
strong_memoize(:milestone_events_per_issue) do
Event
.where(target: milestone_issues, action: [Event::CLOSED, Event::REOPENED])
.where('created_at <= ?', end_date.end_of_day)
.group_by(&:target_id)
end
end
def sum_issues_weight(issues)
issues.map(&:weight).compact.sum
# Use issue creation date as the source of truth for created events
def transformed_create_event_for(issue)
build_burndown_event(issue.created_at, issue.weight, 'created')
end
def closed_and_reopened_issues_by(date)
current_date = date.to_date
# Use issue events as the source of truth for events other than 'created'
def transformed_action_events_for(issue)
events_for_issue = milestone_events_per_issue[issue.id]
return [] unless events_for_issue
closed =
milestone_issues.select do |issue|
(issue.closed_at&.to_date || start_date) == current_date
end
events_for_issue.map do |event|
build_burndown_event(event.created_at, issue.weight, Event::ACTIONS.key(event.action).to_s)
end
end
# If issue is closed but has no closed events, treat it as though closed on milestone start date
def transformed_legacy_closed_event_for(issue)
return [] unless issue.closed?
return [] if milestone_events_per_issue[issue.id]&.any?(&:closed_action?)
reopened = closed.select(&:reopened?)
# Mark burndown chart as inaccurate
@accurate = false
[closed, reopened]
build_burndown_event(milestone.start_date.beginning_of_day, issue.weight, 'closed')
end
def milestone_issues
@milestone_issues ||=
begin
# We make use of `events` table to get the closed_at timestamp.
# `issues.closed_at` can't be used once it's nullified if the issue is
# reopened.
internal_clause =
@milestone.issues_visible_to_user(current_user)
.joins("LEFT OUTER JOIN events e ON issues.id = e.target_id AND e.target_type = 'Issue' AND e.action = #{Event::CLOSED}")
.where("state = 'closed' OR (state = 'opened' AND e.action = #{Event::CLOSED})") # rubocop:disable GitlabSecurity/SqlInjection
rel =
if Gitlab::Database.postgresql?
::Issue
.select("*")
.from(internal_clause.select('DISTINCT ON (issues.id) issues.id, issues.state, issues.weight, e.created_at AS closed_at'))
.order('closed_at ASC')
.pluck('closed_at, weight, state')
else
# In rails 5 mysql's `only_full_group_by` option is enabled by default,
# this means that `GROUP` clause must include all columns used in `SELECT`
# clause. Adding all columns to `GROUP` means that we have now
# duplicates (by issue ID) in records. To get rid of these, we unify them
# on ruby side by issue id. Finally we drop the issue id attribute from records
# because this is not accepted when creating Issue object.
::Issue
.select("*")
.from(internal_clause.select('issues.id, issues.state, issues.weight, e.created_at AS closed_at'))
.group(:id, :closed_at, :weight, :state)
.having('closed_at = MIN(closed_at) OR closed_at IS NULL')
.order('closed_at ASC')
.pluck('id, closed_at, weight, state')
.uniq(&:first)
.map { |attrs| attrs.drop(1) }
end
rel.map { |attrs| Issue.new(*attrs) }
end
def build_burndown_event(created_at, issue_weight, action)
{ created_at: created_at, weight: issue_weight, action: action }
end
end
- milestone = local_assigns[:milestone]
- burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown)
- burndown_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burndown_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burndown_events_path(id: milestone.project.id, milestone_id: milestone.milestoneish_id)
= warning
......@@ -13,7 +14,9 @@
Issues
%button.btn.btn-sm.btn-primary.btn-inverted{ data: { show: 'weight' } }
Issue weight
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), due_date: burndown.due_date.strftime("%Y-%m-%d"), chart_data: burndown.to_json } }
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
burndown_events_path: expose_url(burndown_endpoint) } }
- elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid
......
---
title: Make burndown chart timezone aware
merge_request: 10328
author:
type: fixed
# frozen_string_literal: true
module EE
module API
module GroupMilestones
extend ActiveSupport::Concern
prepended do
include EE::API::MilestoneResponses # rubocop: disable Cop/InjectEnterpriseEditionModule
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of burndown events' do
detail 'This feature was introduced in GitLab 12.1.'
end
get ':id/milestones/:milestone_id/burndown_events' do
authorize! :read_group, user_group
milestone_burndown_events_for(user_group)
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module API
module MilestoneResponses
extend ActiveSupport::Concern
included do
helpers do
def milestone_burndown_events_for(parent)
milestone = parent.milestones.find(params[:milestone_id])
if milestone.supports_burndown_charts?
present Burndown.new(milestone, current_user).as_json
else
render_api_error!("Milestone does not support burndown chart", 405)
end
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module API
module ProjectMilestones
extend ActiveSupport::Concern
prepended do
include EE::API::MilestoneResponses # rubocop: disable Cop/InjectEnterpriseEditionModule
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of burndown events' do
detail 'This feature was introduced in GitLab 12.1.'
end
get ':id/milestones/:milestone_id/burndown_events' do
authorize! :read_milestone, user_project
milestone_burndown_events_for(user_project)
end
end
end
end
end
end
import dateFormat from 'dateformat';
import BurndownChartData from 'ee/burndown_chart/burndown_chart_data';
describe('BurndownChartData', () => {
const milestoneEvents = [
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.190Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.478Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-01T00:00:00.597Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.767Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-03T00:00:00.260Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.152Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.572Z', weight: 2, action: 'reopened' },
{ created_at: '2017-03-03T00:00:00.450Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-03T00:00:00.352Z', weight: 2, action: 'reopened' },
];
const startDate = '2017-03-01';
const dueDate = '2017-03-03';
let burndownChartData;
beforeEach(() => {
burndownChartData = new BurndownChartData(milestoneEvents, startDate, dueDate);
});
describe('generate', () => {
it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([
['2017-03-01', 5, 10],
['2017-03-02', 5, 10],
['2017-03-03', 4, 8],
]);
});
describe('when viewing before due date', () => {
beforeAll(() => {
const today = new Date(2017, 2, 2);
// eslint-disable-next-line no-global-assign
Date = class extends Date {
constructor(date) {
super(date || today);
}
};
});
it('counts until today if milestone due date > date today', () => {
const chartData = burndownChartData.generate();
expect(dateFormat(new Date(), 'yyyy-mm-dd')).toEqual('2017-03-02');
expect(chartData[chartData.length - 1][0]).toEqual('2017-03-02');
});
});
});
});
This diff is collapsed.
......@@ -8,12 +8,13 @@ describe API::GroupMilestones do
let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let!(:closed_milestone) { create(:closed_milestone, group: group, title: 'version1', description: 'closed milestone') }
let!(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone') }
let!(:milestone) { create(:milestone, group: group, title: 'version2', description: 'open milestone', start_date: Date.today, due_date: Date.today + 3.days) }
let(:issue) { create(:issue, created_at: Date.today.beginning_of_day, weight: 2, project: project) }
let(:issues_route) { "/groups/#{group.id}/milestones/#{milestone.id}/issues" }
before do
project.add_developer(user)
milestone.issues << create(:issue, project: project)
milestone.issues << issue
end
it 'matches V4 EE-specific response schema for a list of issues' do
......@@ -22,4 +23,8 @@ describe API::GroupMilestones do
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee')
end
it_behaves_like 'group and project milestone burndowns', '/groups/:id/milestones/:milestone_id/burndown_events' do
let(:route) { "/groups/#{group.id}/milestones" }
end
end
......@@ -5,12 +5,13 @@ require 'spec_helper'
describe API::ProjectMilestones do
let(:user) { create(:user) }
let!(:project) { create(:project, namespace: user.namespace ) }
let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') }
let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone', start_date: Date.today, due_date: Date.today + 3.days) }
let(:issue) { create(:issue, created_at: Date.today.beginning_of_day, weight: 2, project: project) }
let(:issues_route) { "/projects/#{project.id}/milestones/#{milestone.id}/issues" }
before do
project.add_developer(user)
milestone.issues << create(:issue, project: project)
milestone.issues << issue
end
it 'matches V4 EE-specific response schema for a list of issues' do
......@@ -19,4 +20,8 @@ describe API::ProjectMilestones do
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/issues', dir: 'ee')
end
it_behaves_like 'group and project milestone burndowns', '/projects/:id/milestones/:milestone_id/burndown_events' do
let(:route) { "/projects/#{project.id}/milestones" }
end
end
# frozen_string_literal: true
shared_examples_for 'group and project milestone burndowns' do |route_definition|
let(:resource_route) { "#{route}/#{milestone.id}/burndown_events" }
describe "GET #{route_definition}" do
it 'returns burndown events list' do
get api(resource_route, user)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first['created_at'].to_time).to eq(Date.today.beginning_of_day)
expect(json_response.first['weight']).to eq(2)
expect(json_response.first['action']).to eq('created')
end
it 'returns 404 when user is not authorized to read milestone' do
outside_user = create(:user)
get api(resource_route, outside_user)
expect(response).to have_gitlab_http_status(404)
end
end
end
......@@ -95,3 +95,5 @@ module API
end
end
end
API::GroupMilestones.prepend(EE::API::GroupMilestones)
......@@ -116,3 +116,5 @@ module API
end
end
end
API::ProjectMilestones.prepend(EE::API::ProjectMilestones)
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