Skip to content

Commit e998652

Browse files
committed
[WIP] Content and graphs
1 parent c51d1b9 commit e998652

14 files changed

+700
-21
lines changed

frontend/src/app/app.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ import { WorkPackageFullCreateEntryComponent } from 'core-app/features/work-pack
209209
import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packages/routing/wp-full-view/wp-full-view-entry.component';
210210
import { MyPageComponent } from './features/my-page/my-page.component';
211211
import { DashboardComponent } from './features/overview/dashboard.component';
212+
import { BudgetOverviewGraphComponent } from './shared/components/budget-graphs/overview/budget-overview-graph.component';
212213

213214
export function initializeServices(injector:Injector) {
214215
return () => {
@@ -423,5 +424,7 @@ export class OpenProjectModule implements DoBootstrap {
423424

424425
registerCustomElement('opce-my-page', MyPageComponent, { injector });
425426
registerCustomElement('opce-dashboard', DashboardComponent, { injector });
427+
428+
registerCustomElement('opce-budget-overview-graph', BudgetOverviewGraphComponent, { injector });
426429
}
427430
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.op-budget-embedded-graph
2+
&.work-packages-embedded-view--container
3+
height: 100%
4+
max-height: 500px
5+
position: relative
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { Component, Input, SimpleChanges, OnChanges } from '@angular/core';
2+
import { WorkPackageTableConfiguration } from 'core-app/features/work-packages/components/wp-table/wp-table-configuration';
3+
import { ChartOptions } from 'chart.js';
4+
import { I18nService } from 'core-app/core/i18n/i18n.service';
5+
import { GroupObject } from 'core-app/features/hal/resources/wp-collection-resource';
6+
import { CommonModule } from '@angular/common';
7+
import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts';
8+
import ChartDataLabels from 'chartjs-plugin-datalabels';
9+
import PrimerColorsPlugin from '../plugin.primer-colors';
10+
11+
export interface BudgetEmbeddedGraphDataset {
12+
label:string;
13+
queryProps:any;
14+
queryId?:number|string;
15+
groups?:GroupObject[];
16+
}
17+
interface ChartDataSet {
18+
label:string;
19+
data:number[];
20+
}
21+
22+
@Component({
23+
selector: 'op-budget-embedded-graph',
24+
templateUrl: './budget-embedded-graph.html',
25+
styleUrls: ['./budget-embedded-graph.component.sass'],
26+
standalone: true,
27+
imports: [
28+
CommonModule,
29+
BaseChartDirective
30+
],
31+
providers: [
32+
provideCharts(withDefaultRegisterables(ChartDataLabels, PrimerColorsPlugin)),
33+
]
34+
})
35+
export class BudgetEmbeddedGraphComponent implements OnChanges {
36+
@Input() public datasets:BudgetEmbeddedGraphDataset[];
37+
38+
@Input() public chartOptions:ChartOptions;
39+
40+
@Input() chartType = 'bar';
41+
42+
public configuration:WorkPackageTableConfiguration;
43+
44+
public error:string|null = null;
45+
46+
public chartHeight = '100%';
47+
48+
public chartLabels:string[] = [];
49+
50+
public chartData:ChartDataSet[] = [];
51+
52+
public internalChartOptions:ChartOptions;
53+
54+
public initialized = false;
55+
56+
public text = {
57+
noResults: this.i18n.t('js.work_packages.no_results.title'),
58+
};
59+
60+
constructor(readonly i18n:I18nService) {}
61+
62+
ngOnChanges(changes:SimpleChanges) {
63+
if (changes.datasets) {
64+
this.setChartOptions();
65+
this.updateChartData();
66+
67+
if (!changes.datasets.firstChange) {
68+
this.initialized = true;
69+
}
70+
} else if (changes.chartType) {
71+
this.setChartOptions();
72+
}
73+
}
74+
75+
private updateChartData() {
76+
let uniqLabels = _.uniq(this.datasets.reduce((array, dataset) => {
77+
const groups = (dataset.groups || []).map((group) => group.value) as any;
78+
return array.concat(groups);
79+
}, [])) as string[];
80+
81+
const labelCountMaps = this.datasets.map((dataset) => {
82+
const countMap = (dataset.groups || []).reduce<any>((hash, group) => ({
83+
...hash,
84+
[group.value]: group.count,
85+
}), {});
86+
87+
return {
88+
label: dataset.label,
89+
data: uniqLabels.map((label) => countMap[label] || 0),
90+
};
91+
});
92+
93+
uniqLabels = uniqLabels.map((label) => {
94+
if (label === null) {
95+
return this.i18n.t('js.placeholders.default');
96+
}
97+
return label;
98+
});
99+
100+
this.setHeight();
101+
102+
// keep the array in order to update the labels
103+
this.chartLabels.length = 0;
104+
this.chartLabels.push(...uniqLabels);
105+
this.chartData.length = 0;
106+
this.chartData.push(...labelCountMaps);
107+
}
108+
109+
protected setChartOptions() {
110+
const bodyFontColor= getComputedStyle(document.body).getPropertyValue('--body-font-color');
111+
const gridLineColor= getComputedStyle(document.body).getPropertyValue('--borderColor-muted');
112+
const backdropColor= getComputedStyle(document.body).getPropertyValue('--overlay-backdrop-bgColor');
113+
114+
const defaults:ChartOptions = {
115+
color: bodyFontColor,
116+
responsive: true,
117+
maintainAspectRatio: false,
118+
indexAxis: this.chartType === 'horizontalBar' ? 'y' : 'x',
119+
scales: {
120+
r: {
121+
angleLines: {
122+
color: this.isRadarChart() ? gridLineColor : 'transparent',
123+
},
124+
grid: {
125+
color: this.isRadarChart() ? gridLineColor : 'transparent',
126+
},
127+
pointLabels: {
128+
color: this.isRadarChart() ? bodyFontColor : 'transparent',
129+
},
130+
ticks: {
131+
color: this.isRadarChart() ? bodyFontColor : 'transparent',
132+
backdropColor: this.isRadarChart() ? backdropColor : 'transparent',
133+
font: {
134+
weight: 'bold',
135+
size: 14,
136+
},
137+
},
138+
},
139+
y: {
140+
ticks: {
141+
color: this.isBarChart() ? bodyFontColor : 'transparent',
142+
},
143+
grid: {
144+
color: this.isBarChart() ? gridLineColor : 'transparent',
145+
},
146+
border: {
147+
color: this.isBarChart() ? bodyFontColor : 'transparent',
148+
},
149+
},
150+
x: {
151+
ticks: {
152+
color: this.isBarChart() ? bodyFontColor : 'transparent',
153+
},
154+
grid: {
155+
color: this.isBarChart() ? gridLineColor : 'transparent',
156+
},
157+
border: {
158+
color: this.isBarChart() ? bodyFontColor : 'transparent',
159+
},
160+
},
161+
},
162+
plugins: {
163+
legend: {
164+
// Only display legends if more than one dataset is provided.
165+
display: this.datasets.length > 1,
166+
},
167+
datalabels: {
168+
anchor: 'center',
169+
align: this.chartType === 'bar' ? 'top' : 'center',
170+
color: bodyFontColor,
171+
font: {
172+
weight: 'bold',
173+
size: 14,
174+
},
175+
},
176+
},
177+
};
178+
179+
this.internalChartOptions = {
180+
...defaults,
181+
...this.chartOptions,
182+
};
183+
}
184+
185+
public get hasDataToDisplay() {
186+
return this.chartData.length > 0 && this.chartData.some((set) => set.data.length > 0);
187+
}
188+
189+
public get mappedChartType():string {
190+
return this.chartType === 'horizontalBar' ? 'bar' : this.chartType;
191+
}
192+
193+
public get chartDescription():string {
194+
const chartDataDescriptions = _.map(this.chartLabels, (label, index) => {
195+
if (this.chartData.length === 1) {
196+
const allCount = this.chartData[0].data[index];
197+
return `${allCount} ${label}`;
198+
}
199+
const labelCounts = _.map(this.chartData, (dataset) => `${dataset.data[index]} ${dataset.label}`);
200+
return `${label}: ${labelCounts.join(', ')}`;
201+
});
202+
203+
return chartDataDescriptions.join('; ');
204+
}
205+
206+
private setHeight() {
207+
if (this.chartType === 'horizontalBar' && this.datasets && this.datasets[0]) {
208+
const labels:string[] = [];
209+
this.datasets.forEach((d) => { d.groups!.forEach((g) => {
210+
if (!labels.includes(g.value)) {
211+
labels.push(g.value);
212+
}
213+
}); });
214+
let height = labels.length * 40;
215+
216+
if (this.datasets.length > 1) {
217+
// make some more room for the legend
218+
height += 40;
219+
}
220+
221+
// some minimum height e.g. for the labels
222+
height += 40;
223+
224+
this.chartHeight = `${height}px`;
225+
} else {
226+
this.chartHeight = '100%';
227+
}
228+
}
229+
230+
private isBarChart() {
231+
return this.chartType === 'bar' || this.chartType === 'horizontalBar' || this.chartType === 'line';
232+
}
233+
234+
private isRadarChart() {
235+
return this.chartType === 'radar' || this.chartType === 'polarArea';
236+
}
237+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div class="op-budget-embedded-graph work-packages-embedded-view--container loading-indicator--location" #graphContainer [ngStyle]="{height: chartHeight}">
2+
@if (hasDataToDisplay) {
3+
<canvas baseChart
4+
[datasets]="chartData"
5+
[labels]="chartLabels"
6+
[type]="mappedChartType"
7+
[options]="internalChartOptions"
8+
>
9+
{{chartDescription}}
10+
</canvas>
11+
}
12+
@if (!hasDataToDisplay && initialized) {
13+
<op-no-results [title]="text.noResults" />
14+
}
15+
</div>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
budget-embedded-graph
2+
margin-top: 20px
3+
display: block

0 commit comments

Comments
 (0)