Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ import { MyPageComponent } from './features/my-page/my-page.component';
import { DashboardComponent } from './features/overview/dashboard.component';
import { BurndownChartComponent } from './features/backlogs/burndown-chart.component';
import { CreatedResolvedComponent } from './features/backlogs/created-resolved-chart.component';
import { VelocityChartComponent } from './features/backlogs/velocity-chart.component';
import { BudgetByCostTypeComponent } from './shared/components/budget-graphs/overview/budget-by-cost-type.component';
import { ActualCostsComponent } from './shared/components/budget-graphs/overview/actual-costs.component';
import { ProjectTimelineGraphComponent } from './shared/components/project-timeline-graph/project-timeline-graph.component';
Expand Down Expand Up @@ -420,6 +421,7 @@ export class OpenProjectModule implements DoBootstrap {
registerCustomElement('opce-dashboard', DashboardComponent, { injector });
registerCustomElement('opce-burndown-chart', BurndownChartComponent, { injector });
registerCustomElement('opce-created-resolved-chart', CreatedResolvedComponent, { injector });
registerCustomElement('opce-velocity-chart', VelocityChartComponent, { injector });
registerCustomElement('opce-budget-by-cost-type', BudgetByCostTypeComponent, { injector });
registerCustomElement('opce-actual-costs', ActualCostsComponent, { injector });
registerCustomElement('opce-project-timeline-graph', ProjectTimelineGraphComponent, { injector });
Expand Down
170 changes: 170 additions & 0 deletions frontend/src/app/features/backlogs/plugin.average-line.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import type { Chart } from 'chart.js';
import AverageLinePlugin, { AverageLinePluginOptions, isAverageLineVisible, toggleAverageLine } from './plugin.average-line';

describe('AverageLinePlugin', () => {
let ctx:Record<string, unknown> & {
save:ReturnType<typeof vi.fn>;
restore:ReturnType<typeof vi.fn>;
beginPath:ReturnType<typeof vi.fn>;
moveTo:ReturnType<typeof vi.fn>;
lineTo:ReturnType<typeof vi.fn>;
stroke:ReturnType<typeof vi.fn>;
setLineDash:ReturnType<typeof vi.fn>;
fillText:ReturnType<typeof vi.fn>;
};
let getPixelForValue:ReturnType<typeof vi.fn>;
let update:ReturnType<typeof vi.fn>;

beforeEach(() => {
ctx = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
setLineDash: vi.fn(),
fillText: vi.fn(),
};
getPixelForValue = vi.fn().mockReturnValue(120);
update = vi.fn();
});

const chartStub = (withYScale = true) => ({
ctx,
chartArea: { left: 40, right: 400, top: 10, bottom: 250 },
scales: withYScale ? { y: { getPixelForValue, right: 36 } } : {},
options: { font: { family: 'sans-serif', size: 12 } },
update,
} as unknown as Chart);

const draw = (options:AverageLinePluginOptions, chart = chartStub()) => {
AverageLinePlugin.beforeDatasetsDraw!(chart, { cancelable: true }, options);
};

it('draws nothing without a value', () => {
draw({ label: '42', color: 'red' });

expect(ctx.save).not.toHaveBeenCalled();
expect(ctx.stroke).not.toHaveBeenCalled();
expect(ctx.fillText).not.toHaveBeenCalled();
});

it('draws nothing without a y scale', () => {
draw({ value: 42 }, chartStub(false));

expect(ctx.stroke).not.toHaveBeenCalled();
expect(ctx.fillText).not.toHaveBeenCalled();
});

it('draws a dashed line across the full chart area at the value', () => {
draw({ value: 42.5, color: 'red' });

expect(getPixelForValue).toHaveBeenCalledWith(42.5);
expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4]);
expect(ctx.moveTo).toHaveBeenCalledWith(40, 120);
expect(ctx.lineTo).toHaveBeenCalledWith(400, 120);
expect(ctx.stroke).toHaveBeenCalledTimes(1);
expect(ctx.strokeStyle).toBe('red');
});

it('writes the label left-aligned inside the chart area', () => {
draw({ value: 42.5, label: '42,5', color: 'red' });

expect(ctx.fillText).toHaveBeenCalledWith('42,5', 48, 120);
expect(ctx.textAlign).toBe('left');
expect(ctx.textBaseline).toBe('middle');
expect(ctx.fillStyle).toBe('red');
expect(ctx.font).toContain('sans-serif');
expect(ctx.font).toContain('bold');
});

it('falls back to the raw value and a neutral color', () => {
draw({ value: 7 });

expect(ctx.fillText).toHaveBeenCalledWith('7', 48, 120);
expect(ctx.strokeStyle).toBe('gray');
expect(ctx.fillStyle).toBe('gray');
});

it('colors the label separately from the line', () => {
draw({ value: 7, color: 'red', labelColor: 'black' });

expect(ctx.strokeStyle).toBe('red');
expect(ctx.fillStyle).toBe('black');
});

describe('toggling', () => {
it('is visible by default', () => {
expect(isAverageLineVisible(chartStub())).toBe(true);
});

it('draws nothing once hidden', () => {
const chart = chartStub();
toggleAverageLine(chart);

draw({ value: 7 }, chart);

expect(isAverageLineVisible(chart)).toBe(false);
expect(ctx.stroke).not.toHaveBeenCalled();
expect(ctx.fillText).not.toHaveBeenCalled();
});

it('draws again when toggled back', () => {
const chart = chartStub();
toggleAverageLine(chart);
toggleAverageLine(chart);

draw({ value: 7 }, chart);

expect(isAverageLineVisible(chart)).toBe(true);
expect(ctx.stroke).toHaveBeenCalledTimes(1);
});

it('redraws the chart', () => {
toggleAverageLine(chartStub());

expect(update).toHaveBeenCalledTimes(1);
});

it('hides the line only on the toggled chart', () => {
toggleAverageLine(chartStub());

expect(isAverageLineVisible(chartStub())).toBe(true);
});
});

it('restores the canvas state after drawing', () => {
draw({ value: 7 });

expect(ctx.save).toHaveBeenCalledTimes(1);
expect(ctx.restore).toHaveBeenCalledTimes(1);
});
});
121 changes: 121 additions & 0 deletions frontend/src/app/features/backlogs/plugin.average-line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { Chart, ChartType, Plugin } from 'chart.js';
import { toFont } from 'chart.js/helpers';

export interface AverageLinePluginOptions {
value?:number;
label?:string;
color?:string;
labelColor?:string;
}

declare module 'chart.js' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface PluginOptionsByType<TType extends ChartType> {
averageLine:AverageLinePluginOptions;
}
}

export const AVERAGE_LINE_DASH = [4, 4];
const LABEL_PADDING = 8;

const chartsWithHiddenLine = new WeakSet<Chart>();

export function isAverageLineVisible(chart:Chart):boolean {
return !chartsWithHiddenLine.has(chart);
}

export function toggleAverageLine(chart:Chart):void {
if (isAverageLineVisible(chart)) {
chartsWithHiddenLine.add(chart);
} else {
chartsWithHiddenLine.delete(chart);
}
chart.update();
}

function labelText(options:AverageLinePluginOptions):string {
return options.label ?? String(options.value);
}

function labelFont(chart:Chart):string {
return toFont({ weight: 'bold' }, chart.options.font).string;
}

const AverageLinePlugin:Plugin<ChartType, AverageLinePluginOptions> = {
id: 'averageLine',

afterLayout(chart, _args, options) {
const xScale = chart.scales.x;
if (options.value === undefined || !xScale) {
return;
}

const { ctx } = chart;
ctx.save();
ctx.font = labelFont(chart);
const inset = ctx.measureText(labelText(options)).width + (2 * LABEL_PADDING);
ctx.restore();

xScale.left += inset;
xScale.width -= inset;
xScale.configure();
},

beforeDatasetsDraw(chart, _args, options) {
const yScale = chart.scales.y;
if (options.value === undefined || !yScale || !isAverageLineVisible(chart)) {
return;
}
Comment thread
EinLama marked this conversation as resolved.

const { ctx, chartArea } = chart;
const y = yScale.getPixelForValue(options.value);
const color = options.color ?? 'gray';

ctx.save();

ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.setLineDash(AVERAGE_LINE_DASH);
ctx.beginPath();
ctx.moveTo(chart.scales.x?.left ?? chartArea.left, y);
ctx.lineTo(chartArea.right, y);
ctx.stroke();

ctx.fillStyle = options.labelColor ?? color;
ctx.font = labelFont(chart);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(labelText(options), chartArea.left + LABEL_PADDING, y);

ctx.restore();
},
};

export default AverageLinePlugin;
33 changes: 33 additions & 0 deletions frontend/src/app/features/backlogs/velocity-chart.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<div class="position-relative" style="height: 320px">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to extract this hard wired height into a class to then be used by all the charts on the reports page. The Burndown PR does it admittedly with a name that is not generic.

Probably to be done after merging both PRs.

<canvas baseChart
Comment thread
EinLama marked this conversation as resolved.
role="img"
[attr.aria-labelledby]="summaryId"
[attr.aria-describedby]="tableId"
[data]="barChartData()"
[options]="barChartOptions()"
[plugins]="plugins"
[type]="'bar'"></canvas>
</div>

<p [id]="summaryId" class="sr-only">{{ parsedData().summary }}</p>

<table [id]="tableId" class="sr-only">
<thead>
<tr>
<th scope="col">{{ parsedData().sprintColumnTitle }}</th>
@for (dataset of parsedData().datasets; track dataset.label) {
<th scope="col">{{ dataset.label }}</th>
}
</tr>
</thead>
<tbody>
@for (sprint of parsedData().labels; track $index; let sprintIndex = $index) {
<tr>
<th scope="row">{{ sprint }}</th>
@for (dataset of parsedData().datasets; track dataset.label) {
<td>{{ dataset.data[sprintIndex] }}</td>
}
</tr>
}
</tbody>
Comment thread
EinLama marked this conversation as resolved.
</table>
Loading
Loading