Skip to content

Commit 9e12e6f

Browse files
atarix83FrancescoMolinaro
authored andcommitted
Merged in task/dspace-cris-2025_02_x/DSC-3043_new (pull request DSpace#5284)
[DSC-3043] Async-load full report to avoid stale chart data Approved-by: Francesco Molinaro
2 parents dc11543 + da256e4 commit 9e12e6f

6 files changed

Lines changed: 232 additions & 95 deletions

File tree

‎src/app/statistics-page/cris-statistics-page/cris-statistics-page.component.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ <h2>{{'statistics.categories.title' | translate}}</h2>
6868
[category]="selectedCategory"
6969
[reports]="reports"
7070
[selectedReportId]="selectedReportId"
71-
[selectedFullReport]="(selectedFullReport$ | async)">
71+
[selectedFullReport]="selectedFullReport$">
7272
</ds-statistics-chart>
7373
}
7474
</div>

‎src/app/statistics-page/cris-statistics-page/cris-statistics-page.component.spec.ts‎

Lines changed: 139 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -226,71 +226,169 @@ describe('CrisStatisticsPageComponent', () => {
226226
expect(component.selectedReportId).toBeUndefined();
227227
});
228228

229-
it('should set selectedReportId to the first report id if no reportType query param is present', () => {
229+
it('should emit null on selectedFullReport$ before loading the new report', () => {
230230
const category = { id: 'category1' };
231-
const reports = [{ id: 'report1', reportType: 'type1' }, { id: 'report2', reportType: 'type2' }];
231+
const reports = [{ id: 'report1', reportType: 'type1' }];
232232
spyOn(component, 'getReports$').and.returnValue(of(reports as UsageReport[]));
233233
spyOn(component, 'getReportId').and.returnValue(of(null));
234234
spyOn(component, 'getCategoryId').and.returnValue(of(null));
235235
spyOn(component, 'setStatisticsState');
236236

237+
const emitted: (UsageReport | null)[] = [];
238+
(component as any).selectedFullReport$.subscribe((value) => emitted.push(value));
239+
237240
component.getUserReports(category);
238241

239-
expect(component.setStatisticsState).toHaveBeenCalledWith('report1', 'category1');
240-
expect(component.selectedReportId).toBe('report1');
242+
// first emitted value is the initial null from the BehaviorSubject,
243+
// second is the reset to null done at the start of getUserReports
244+
expect(emitted[0]).toBeNull();
245+
expect(emitted[1]).toBeNull();
241246
});
242247

243-
it('should set selectedReportId to the report id matching the reportType query param', () => {
244-
const category = { id: 'category1' };
245-
const reports = [{ id: 'report1', reportType: 'type1' }, { id: 'report2', reportType: 'type2' }];
246-
spyOn(component, 'getReports$').and.returnValue(of(reports as UsageReport[]));
247-
spyOn(component, 'getReportId').and.returnValue(of(null));
248-
spyOn(component, 'getCategoryId').and.returnValue(of(null));
249-
spyOn(component, 'setStatisticsState');
248+
describe('loadFullReport', () => {
250249

251-
component.getUserReports(category, 'type2');
250+
beforeEach(() => {
251+
(component as any).fullReportCache.clear();
252+
(component as any).inFlightRequests.clear();
253+
});
254+
255+
it('should return of(null) and reset currentReportId when reportId is falsy', (done: DoneFn) => {
256+
(component as any).currentReportId = 'something';
257+
258+
component.loadFullReport(null).subscribe((result) => {
259+
expect(result).toBeNull();
260+
expect((component as any).currentReportId).toBeNull();
261+
done();
262+
});
263+
});
264+
265+
it('should return the cached report without calling the service when present in cache', (done: DoneFn) => {
266+
const reportId = '1911e8a4-6939-490c-b58b-a5d70f8d91fb_TotalVisits';
267+
const cachedReport = { id: reportId } as UsageReport;
268+
(component as any).fullReportCache.set(reportId, cachedReport);
269+
spyOn(usageReportServiceStub, 'findById').and.callThrough();
270+
271+
component.loadFullReport(reportId).subscribe((result) => {
272+
expect(result).toBe(cachedReport);
273+
expect(usageReportServiceStub.findById).not.toHaveBeenCalled();
274+
done();
275+
});
276+
});
277+
278+
it('should call usageReportService.findById when the report is not cached', (done: DoneFn) => {
279+
const reportId = '1911e8a4-6939-490c-b58b-a5d70f8d91fb_TotalVisits';
280+
spyOn(usageReportServiceStub, 'findById').and.callThrough();
281+
282+
component.loadFullReport(reportId).subscribe((result) => {
283+
expect(usageReportServiceStub.findById).toHaveBeenCalledWith(reportId, false, true);
284+
expect(result.id).toBe(reportId);
285+
expect((component as any).currentReportId).toBe(reportId);
286+
done();
287+
});
288+
});
289+
290+
it('should reuse the same observable for concurrent requests of the same reportId', () => {
291+
const reportId = '1911e8a4-6939-490c-b58b-a5d70f8d91fb_TotalVisits';
292+
spyOn(usageReportServiceStub, 'findById').and.callThrough();
293+
294+
const first$ = component.loadFullReport(reportId);
295+
const second$ = component.loadFullReport(reportId);
296+
297+
expect(first$).toBe(second$);
298+
expect(usageReportServiceStub.findById).toHaveBeenCalledTimes(1);
299+
});
252300

253-
expect(component.setStatisticsState).toHaveBeenCalledWith('report2', 'category1');
254-
expect(component.selectedReportId).toBe('report2');
255301
});
256302

257-
it('should set selectedReportId to the first report id if reportType query param does not match any report', () => {
258-
const category = { id: 'category1' };
259-
const reports = [{ id: 'report1', reportType: 'type1' }, { id: 'report2', reportType: 'type2' }];
260-
spyOn(component, 'getReports$').and.returnValue(of(reports as UsageReport[]));
261-
spyOn(component, 'getReportId').and.returnValue(of(null));
262-
spyOn(component, 'getCategoryId').and.returnValue(of(null));
263-
spyOn(component, 'setStatisticsState');
303+
describe('setReportId', () => {
264304

265-
component.getUserReports(category, 'non_existing_type');
305+
beforeEach(() => {
306+
(component as any).fullReportCache.clear();
307+
});
308+
309+
it('should cache the report and update selectedFullReport$ when reportId matches currentReportId', () => {
310+
const reportId = 'report1';
311+
const report = { id: reportId } as UsageReport;
312+
(component as any).currentReportId = reportId;
313+
314+
(component as any).setReportId(report, reportId);
315+
316+
expect((component as any).fullReportCache.get(reportId)).toBe(report);
317+
expect((component as any).selectedFullReport$.value).toBe(report);
318+
});
319+
320+
it('should not update selectedFullReport$ when reportId does not match currentReportId (stale response)', () => {
321+
const report = { id: 'oldReport' } as UsageReport;
322+
(component as any).currentReportId = 'newerReport';
323+
(component as any).selectedFullReport$.next(null);
324+
325+
(component as any).setReportId(report, 'oldReport');
326+
327+
expect((component as any).selectedFullReport$.value).toBeNull();
328+
// it is still cached for later reuse even if stale
329+
expect((component as any).fullReportCache.get('oldReport')).toBe(report);
330+
});
331+
332+
it('should not cache a null/falsy report', () => {
333+
(component as any).currentReportId = 'report1';
334+
335+
(component as any).setReportId(null, 'report1');
336+
337+
expect((component as any).fullReportCache.has('report1')).toBe(false);
338+
expect((component as any).selectedFullReport$.value).toBeNull();
339+
});
266340

267-
expect(component.setStatisticsState).toHaveBeenCalledWith('report1', 'category1');
268-
expect(component.selectedReportId).toBe('report1');
269341
});
270342

271-
it('should set selectedReportId and categoryId from state if they exist', () => {
272-
const category = { id: 'category1' };
273-
const reports = [{ id: 'report1', reportType: 'type1' }, { id: 'report2', reportType: 'type2' }];
274-
spyOn(component, 'getReports$').and.returnValue(of(reports as UsageReport[]));
275-
spyOn(component, 'getReportId').and.returnValue(of('report1'));
276-
spyOn(component, 'getCategoryId').and.returnValue(of('category1'));
277-
spyOn(component, 'setStatisticsState');
343+
describe('changeReport', () => {
278344

279-
component.getUserReports(category);
345+
it('should update statistics state, selectedReportId and selectedFullReport$', (done: DoneFn) => {
346+
const reportId = '1911e8a4-6939-490c-b58b-a5d70f8d91fb_TotalVisits';
347+
spyOn(component, 'getCategoryId').and.returnValue(of('category1'));
348+
spyOn(component, 'setStatisticsState');
349+
350+
component.changeReport(reportId);
351+
352+
(component as any).selectedFullReport$.subscribe((report: UsageReport) => {
353+
if (report) {
354+
expect(component.setStatisticsState).toHaveBeenCalledWith(reportId, 'category1');
355+
expect(component.selectedReportId).toBe(reportId);
356+
expect(report.id).toBe(reportId);
357+
done();
358+
}
359+
});
360+
});
361+
362+
it('should emit null on selectedFullReport$ before loading the new report', () => {
363+
spyOn(component, 'getCategoryId').and.returnValue(of('category1'));
364+
spyOn(component, 'setStatisticsState');
365+
366+
const emitted: (UsageReport | null)[] = [];
367+
(component as any).selectedFullReport$.subscribe((value) => emitted.push(value));
368+
369+
component.changeReport('1911e8a4-6939-490c-b58b-a5d70f8d91fb_TotalVisits');
370+
371+
expect(emitted[0]).toBeNull();
372+
expect(emitted[1]).toBeNull();
373+
});
280374

281-
expect(component.setStatisticsState).toHaveBeenCalledWith('report1', 'category1');
282375
});
283376

284-
it('should handle null category gracefully', () => {
285-
spyOn(component, 'getReports$').and.returnValue(of([]));
286-
spyOn(component, 'getReportId').and.returnValue(of(null));
287-
spyOn(component, 'getCategoryId').and.returnValue(of(null));
288-
spyOn(component, 'setStatisticsState');
377+
describe('clearFullReportCache', () => {
289378

290-
component.getUserReports(null);
379+
it('should clear caches, reset currentReportId and emit null on selectedFullReport$', () => {
380+
(component as any).fullReportCache.set('r1', { id: 'r1' } as UsageReport);
381+
(component as any).inFlightRequests.set('r1', of({ id: 'r1' } as UsageReport));
382+
(component as any).currentReportId = 'r1';
383+
384+
(component as any).clearFullReportCache();
385+
386+
expect((component as any).fullReportCache.size).toBe(0);
387+
expect((component as any).inFlightRequests.size).toBe(0);
388+
expect((component as any).currentReportId).toBeNull();
389+
expect((component as any).selectedFullReport$.value).toBeNull();
390+
});
291391

292-
expect(component.setStatisticsState).not.toHaveBeenCalled();
293-
expect(component.selectedReportId).toBeUndefined();
294392
});
295393

296394
});

‎src/app/statistics-page/cris-statistics-page/cris-statistics-page.component.ts‎

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { SiteDataService } from '../../core/data/site-data.service';
4646
import { redirectOn4xx } from '../../core/shared/authorized.operators';
4747
import { DSpaceObject } from '../../core/shared/dspace-object.model';
4848
import {
49+
getFirstCompletedRemoteData,
4950
getFirstSucceededRemoteData,
5051
getRemoteDataPayload,
5152
} from '../../core/shared/operators';
@@ -143,8 +144,7 @@ export class CrisStatisticsPageComponent implements OnInit, OnDestroy {
143144
/**
144145
* The fully loaded report (with points) for the currently selected chart report.
145146
*/
146-
private selectedFullReportSubject = new BehaviorSubject<UsageReport | null>(null);
147-
selectedFullReport$ = this.selectedFullReportSubject.asObservable();
147+
protected selectedFullReport$ = new BehaviorSubject<UsageReport | null>(null);
148148

149149
constructor(
150150
protected route: ActivatedRoute,
@@ -249,14 +249,13 @@ export class CrisStatisticsPageComponent implements OnInit, OnDestroy {
249249
* @param reportType
250250
*/
251251
getUserReports(category, reportType = this.route?.snapshot?.queryParams?.reportType) {
252-
this.reports$ =
253-
of(category)
254-
.pipe(
255-
switchMap(c => c == null ? [] : this.getReports$(c.id)),
256-
);
252+
this.selectedFullReport$.next(null);
253+
this.reports$ = of(category).pipe(
254+
switchMap(c => c == null ? [] : this.getReports$(c.id)),
255+
);
257256
combineLatest([
258257
this.reports$, this.getReportId(), this.getCategoryId(),
259-
]).subscribe(([report, reportId, categoryId]) => {
258+
]).pipe(switchMap(([report, reportId, categoryId]) => {
260259
if (!reportId && !categoryId) {
261260
let reportToShowId = report[0].id;
262261
if (reportType) {
@@ -270,7 +269,11 @@ export class CrisStatisticsPageComponent implements OnInit, OnDestroy {
270269
} else {
271270
this.setStatisticsState(reportId, categoryId);
272271
}
273-
this.loadFullReport(this.selectedReportId || reportId || report[0]?.id);
272+
const availableReportId = this.selectedReportId || reportId || report[0]?.id;
273+
return combineLatest([this.loadFullReport(availableReportId), of(availableReportId)]);
274+
}),
275+
).subscribe(([fullReport, reportId]: [UsageReport, string]) => {
276+
this.setReportId(fullReport, reportId);
274277
});
275278
}
276279

@@ -339,58 +342,52 @@ export class CrisStatisticsPageComponent implements OnInit, OnDestroy {
339342
* @param reportId
340343
*/
341344
changeReport(reportId: string) {
342-
this.getCategoryId().subscribe((categoryId) => {
343-
this.setStatisticsState(reportId, categoryId);
344-
this.selectedReportId = reportId;
345-
this.loadFullReport(reportId);
346-
});
345+
this.selectedFullReport$.next(null);
346+
this.getCategoryId()
347+
.pipe(switchMap((categoryId) => combineLatest([this.loadFullReport(reportId), of(categoryId)])))
348+
.subscribe(([fullReport, categoryId]: [UsageReport, string]) => {
349+
this.setStatisticsState(reportId, categoryId);
350+
this.selectedReportId = reportId;
351+
this.setReportId(fullReport, reportId);
352+
});
347353
}
348354

349355
/**
350356
* Load the full report (with points) for the given report id via findOne.
351357
* @param reportId the composite id (uuid_reportKey)
352358
*/
353-
loadFullReport(reportId: string) {
359+
loadFullReport(reportId: string): Observable<UsageReport|null> {
354360
if (!reportId) {
355361
this.currentReportId = null;
356-
this.selectedFullReportSubject.next(null);
357-
return;
362+
return of(null);
358363
}
359364
this.currentReportId = reportId;
360365
const cachedReport = this.fullReportCache.get(reportId);
361366
if (cachedReport) {
362-
this.selectedFullReportSubject.next(cachedReport);
363-
return;
367+
return of(cachedReport);
364368
}
365-
this.selectedFullReportSubject.next(null);
366-
let request$ = this.inFlightRequests.get(reportId);
367-
if (!request$) {
368-
request$ = this.usageReportService.findById(reportId, false, true).pipe(
369-
getFirstSucceededRemoteData(),
369+
370+
let fullReport$: Observable<UsageReport> = this.inFlightRequests.get(reportId);
371+
if (!fullReport$) {
372+
fullReport$ = this.usageReportService.findById(reportId, false, true).pipe(
373+
getFirstCompletedRemoteData(),
370374
getRemoteDataPayload(),
371-
take(1),
372375
shareReplay({ bufferSize: 1, refCount: true }),
373376
finalize(() => {
374377
this.inFlightRequests.delete(reportId);
375378
}),
376379
);
377-
this.inFlightRequests.set(reportId, request$);
380+
this.inFlightRequests.set(reportId, fullReport$);
378381
}
379-
request$.subscribe((fullReport: UsageReport) => {
380-
if (fullReport) {
381-
this.fullReportCache.set(reportId, fullReport);
382-
}
383-
if (reportId === this.currentReportId) {
384-
this.selectedFullReportSubject.next(fullReport);
385-
}
386-
});
382+
return fullReport$;
383+
387384
}
388385

389386
private clearFullReportCache() {
390387
this.fullReportCache.clear();
391388
this.inFlightRequests.clear();
392389
this.currentReportId = null;
393-
this.selectedFullReportSubject.next(null);
390+
this.selectedFullReport$.next(null);
394391
}
395392

396393
/**
@@ -415,6 +412,15 @@ export class CrisStatisticsPageComponent implements OnInit, OnDestroy {
415412
);
416413
}
417414

415+
private setReportId(report: UsageReport, reportId) {
416+
if (report) {
417+
this.fullReportCache.set(reportId, report);
418+
}
419+
if (reportId === this.currentReportId) {
420+
this.selectedFullReport$.next(report);
421+
}
422+
}
423+
418424
/**
419425
* Clean the statistics state in component's destroy
420426
*/

‎src/app/statistics-page/cris-statistics-page/statistics-chart/statistics-chart.component.html‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
}
44

55
@if (reports) {
6+
@let selectedReport = selectedReport$ | async;
67
<div class="container">
78
<div class="row">
89
<div class="col-md-12 mb-40">
@@ -18,13 +19,13 @@ <h2>{{'statistics.reports.title' | translate}}</h2>
1819
</li>
1920
}
2021
</ul>
21-
@if (!selectedReport || !selectedFullReport) {
22+
@if (!selectedReport || (selectedFullReport | async) === null || (selectedFullReport | async) === undefined) {
2223
<ds-loading></ds-loading>
2324
}
24-
@if (selectedReport && selectedFullReport) {
25+
@if (selectedReport && (selectedFullReport | async) !== null) {
2526
<ds-statistics-chart-wrapper [type]="selectedReport.viewMode"
2627
[categoryType]="categoryType"
27-
[report]="selectedFullReport">
28+
[report]="selectedFullReport | async">
2829
</ds-statistics-chart-wrapper>
2930
}
3031
</div>

0 commit comments

Comments
 (0)