diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts index c78fef0521e..08c7c7c6b8c 100644 --- a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts +++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts @@ -16,6 +16,7 @@ import { of as observableOf } from 'rxjs'; import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { Collection } from '../../core/shared/collection.model'; import { UsageReport } from '../../core/statistics/models/usage-report.model'; import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; @@ -82,6 +83,7 @@ describe('CollectionStatisticsPageComponent', () => { { provide: DSpaceObjectDataService, useValue: {} }, { provide: DSONameService, useValue: nameService }, { provide: AuthService, useValue: authService }, + { provide: PaginationService, useValue: { getCurrentPagination: () => observableOf({ currentPage: 1, pageSize: 10 }) } }, ], }) .compileComponents(); diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts index e29e37880f4..8ce3eb9cb1c 100644 --- a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts +++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts @@ -16,6 +16,7 @@ import { of as observableOf } from 'rxjs'; import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { Community } from '../../core/shared/community.model'; import { UsageReport } from '../../core/statistics/models/usage-report.model'; import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; @@ -82,6 +83,7 @@ describe('CommunityStatisticsPageComponent', () => { { provide: DSpaceObjectDataService, useValue: {} }, { provide: DSONameService, useValue: nameService }, { provide: AuthService, useValue: authService }, + { provide: PaginationService, useValue: { getCurrentPagination: () => observableOf({ currentPage: 1, pageSize: 10 }) } }, ], }) .compileComponents(); diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts index f5f3361cce1..133e98acfc3 100644 --- a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts +++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts @@ -16,6 +16,7 @@ import { of as observableOf } from 'rxjs'; import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { Item } from '../../core/shared/item.model'; import { UsageReport } from '../../core/statistics/models/usage-report.model'; import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; @@ -82,6 +83,7 @@ describe('ItemStatisticsPageComponent', () => { { provide: DSpaceObjectDataService, useValue: {} }, { provide: DSONameService, useValue: nameService }, { provide: AuthService, useValue: authService }, + { provide: PaginationService, useValue: { getCurrentPagination: () => observableOf({ currentPage: 1, pageSize: 10 }) } }, ], }) .compileComponents(); diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts index 9b23afdd74c..a7c5192fe88 100644 --- a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts +++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts @@ -17,6 +17,7 @@ import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { SiteDataService } from '../../core/data/site-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { Site } from '../../core/shared/site.model'; import { UsageReport } from '../../core/statistics/models/usage-report.model'; import { UsageReportDataService } from '../../core/statistics/usage-report-data.service'; @@ -83,6 +84,7 @@ describe('SiteStatisticsPageComponent', () => { { provide: DSONameService, useValue: nameService }, { provide: SiteDataService, useValue: siteService }, { provide: AuthService, useValue: authService }, + { provide: PaginationService, useValue: { getCurrentPagination: () => observableOf({ currentPage: 1, pageSize: 10 }) } }, ], }) .compileComponents(); diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.html b/src/app/statistics-page/statistics-table/statistics-table.component.html index efa9ce43d99..43c2702acdd 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.html +++ b/src/app/statistics-page/statistics-table/statistics-table.component.html @@ -5,32 +5,41 @@

{{ 'statistics.table.title.' + report.reportType | translate }}

- - - - - - - - - - - - - - - - -
- {{ header }} -
- {{ getLabel(point) | async }} - - {{ point.values[header] }} -
+ + + + + + + + + + + + + + + + + + +
+ {{ header }} +
+ {{ getLabel(point) | async }} + + {{ point.values[header] }} +
+ +
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.scss b/src/app/statistics-page/statistics-table/statistics-table.component.scss index 4e173c040ab..e3e68b5fc46 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.scss +++ b/src/app/statistics-page/statistics-table/statistics-table.component.scss @@ -2,6 +2,12 @@ th, td { padding: 0.5rem; } +// Space below the pagination top bar (results-per-page gear + "showing" detail) +// so it isn't glued to the table above it. +:host ::ng-deep ds-pagination .pagination-masked.top { + margin-bottom: 0.75rem; +} + td { width: 50px; max-width: 50px; diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts index 105a7623d6b..d8e010ecc05 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts +++ b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts @@ -1,4 +1,8 @@ -import { DebugElement } from '@angular/core'; +import { + Component, + DebugElement, + Input, +} from '@angular/core'; import { ComponentFixture, TestBed, @@ -6,19 +10,52 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { StatisticsTableComponent } from './statistics-table.component'; +/** + * Lightweight stand-in for ds-pagination so the table can be tested in isolation; the current page is + * driven through the (mocked) PaginationService, exactly as the real component does via the URL. + */ +@Component({ + selector: 'ds-pagination', + standalone: true, + template: '', +}) +class MockPaginationComponent { + @Input() paginationOptions: PaginationComponentOptions; + @Input() collectionSize: number; + @Input() hideGear: boolean; + @Input() hidePaginationDetail: boolean; + @Input() hideSortOptions: boolean; + @Input() retainScrollPosition: boolean; +} + describe('StatisticsTableComponent', () => { let component: StatisticsTableComponent; let de: DebugElement; let fixture: ComponentFixture; + let currentPagination$: BehaviorSubject; + + const paginationService = { + getCurrentPagination: (_id: string, _options: PaginationComponentOptions) => currentPagination$.asObservable(), + }; + + const setPage = (currentPage: number, pageSize = 10) => { + currentPagination$.next(Object.assign(new PaginationComponentOptions(), { currentPage, pageSize })); + }; beforeEach(waitForAsync(() => { + currentPagination$ = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 10 })); + TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot(), @@ -27,8 +64,13 @@ describe('StatisticsTableComponent', () => { providers: [ { provide: DSpaceObjectDataService, useValue: {} }, { provide: DSONameService, useValue: {} }, + { provide: PaginationService, useValue: paginationService }, ], }) + .overrideComponent(StatisticsTableComponent, { + remove: { imports: [PaginationComponent] }, + add: { imports: [MockPaginationComponent] }, + }) .compileComponents(); })); @@ -51,6 +93,10 @@ describe('StatisticsTableComponent', () => { it ('should not display a table', () => { expect(de.query(By.css('table'))).toBeNull(); }); + + it('should not display a pagination control', () => { + expect(de.query(By.directive(MockPaginationComponent))).toBeNull(); + }); }); describe('when the storage report has data', () => { @@ -96,5 +142,83 @@ describe('StatisticsTableComponent', () => { expect(de.query(By.css('td.item_2-downloads-data')).nativeElement.innerText) .toEqual('8'); }); + + it('should wrap the table in a ds-pagination control with the report size', () => { + const pagination = de.query(By.directive(MockPaginationComponent)); + expect(pagination).toBeTruthy(); + expect(pagination.componentInstance.collectionSize).toEqual(2); + }); + + it('should hide the page-size selector when everything fits on a single page', () => { + expect(de.query(By.directive(MockPaginationComponent)).componentInstance.hideGear).toBeTrue(); + }); + }); + + describe('when the report has more points than the page size', () => { + + const numberOfPoints = 25; + + beforeEach(() => { + const points = []; + for (let i = 0; i < numberOfPoints; i++) { + points.push({ + id: `item_${i}`, + label: `item_${i}`, + values: { + views: i, + }, + }); + } + component.report = Object.assign(new UsageReport(), { points }); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should pass the full report size to the pagination control', () => { + expect(de.query(By.directive(MockPaginationComponent)).componentInstance.collectionSize) + .toEqual(numberOfPoints); + }); + + it('should offer the page-size selector with its options when there is more than one page', () => { + const pagination = de.query(By.directive(MockPaginationComponent)).componentInstance; + expect(pagination.hideGear).toBeFalse(); + expect(pagination.paginationOptions.pageSizeOptions).toEqual([10, 20, 40, 60, 80, 100]); + }); + + it('should render more rows when a larger page size is selected', () => { + setPage(1, 20); + fixture.detectChanges(); + + expect(de.queryAll(By.css('[data-test="statistics-label"]')).length).toEqual(20); + expect(de.query(By.css('td.item_19-views-data'))).toBeTruthy(); + }); + + it('should only render the first page of points', () => { + expect(de.queryAll(By.css('[data-test="statistics-label"]')).length) + .toEqual(component.pageSize); + expect(de.query(By.css('td.item_0-views-data'))).toBeTruthy(); + expect(de.query(By.css('td.item_10-views-data'))).toBeNull(); + }); + + it('should render the next page of points when the current page changes', () => { + setPage(2); + fixture.detectChanges(); + + expect(de.query(By.css('td.item_0-views-data'))).toBeNull(); + expect(de.query(By.css('td.item_10-views-data'))).toBeTruthy(); + expect(de.queryAll(By.css('[data-test="statistics-label"]')).length) + .toEqual(component.pageSize); + }); + + it('should render the remaining points on the last page', () => { + const lastPage = Math.ceil(numberOfPoints / component.pageSize); + setPage(lastPage); + fixture.detectChanges(); + + const remaining = numberOfPoints - (lastPage - 1) * component.pageSize; + expect(de.queryAll(By.css('[data-test="statistics-label"]')).length) + .toEqual(remaining); + expect(de.query(By.css(`td.item_${numberOfPoints - 1}-views-data`))).toBeTruthy(); + }); }); }); diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.ts b/src/app/statistics-page/statistics-table/statistics-table.component.ts index cd717305681..cb5f1c7fe12 100644 --- a/src/app/statistics-page/statistics-table/statistics-table.component.ts +++ b/src/app/statistics-page/statistics-table/statistics-table.component.ts @@ -20,6 +20,7 @@ import { map } from 'rxjs/operators'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; import { getFinishedRemoteData, getRemoteDataPayload, @@ -29,6 +30,8 @@ import { UsageReport, } from '../../core/statistics/models/usage-report.model'; import { isEmpty } from '../../shared/empty.util'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; /** * Component representing a statistics table for a given usage report. @@ -38,7 +41,7 @@ import { isEmpty } from '../../shared/empty.util'; templateUrl: './statistics-table.component.html', styleUrls: ['./statistics-table.component.scss'], standalone: true, - imports: [NgIf, NgFor, AsyncPipe, TranslateModule], + imports: [NgIf, NgFor, AsyncPipe, TranslateModule, PaginationComponent], }) export class StatisticsTableComponent implements OnInit { @@ -48,6 +51,12 @@ export class StatisticsTableComponent implements OnInit { @Input() report: UsageReport; + /** + * The number of points (e.g. datasets, countries, cities) to show per page. + */ + @Input() + pageSize = 10; + /** * Boolean indicating whether the usage report has data */ @@ -58,9 +67,20 @@ export class StatisticsTableComponent implements OnInit { */ headers: string[]; + /** + * Configuration for the {@link PaginationComponent} (ds-pagination) used to page through the points. + */ + paginationOptions: PaginationComponentOptions; + + /** + * The points to render for the currently selected page. + */ + paginatedPoints$: Observable; + constructor( protected dsoService: DSpaceObjectDataService, protected nameService: DSONameService, + protected paginationService: PaginationService, private translateService: TranslateService, ) { @@ -71,6 +91,23 @@ export class StatisticsTableComponent implements OnInit { if (this.hasData) { this.headers = Object.keys(this.report.points[0].values); } + + this.paginationOptions = Object.assign(new PaginationComponentOptions(), { + // Unique per report AND scope so multiple tables paginate independently and a report doesn't pick up + // another scope's page from the URL. report.id is `_`, e.g. `_TotalVisits`. + id: `stats-${this.report.id}`, + // pageSize is the default; users can change it via the ds-pagination "results per page" selector. + pageSize: this.pageSize, + pageSizeOptions: [10, 20, 40, 60, 80, 100], + currentPage: 1, + }); + + this.paginatedPoints$ = this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions).pipe( + map((pagination) => { + const start = (pagination.currentPage - 1) * pagination.pageSize; + return this.report.points.slice(start, start + pagination.pageSize); + }), + ); } /**