Merge pull request #851 from atmire/usage-reports

Add statistics pages
This commit is contained in:
Tim Donohue
2020-10-07 12:34:04 -05:00
committed by GitHub
34 changed files with 1324 additions and 23 deletions

View File

@@ -20,6 +20,8 @@ import {
COLLECTION_CREATE_PATH
} from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -69,7 +71,21 @@ import { CollectionPageAdministratorGuard } from './collection-page-administrato
pathMatch: 'full',
canActivate: [AuthenticatedGuard]
}
]
],
data: {
menu: {
public: [{
id: 'statistics_collection_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/collections/:id/',
} as LinkMenuItemModel,
}],
},
},
},
])
],

View File

@@ -12,6 +12,8 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -45,7 +47,21 @@ import { CommunityPageAdministratorGuard } from './community-page-administrator.
component: CommunityPageComponent,
pathMatch: 'full',
}
]
],
data: {
menu: {
public: [{
id: 'statistics_community_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/communities/:id/',
} as LinkMenuItemModel,
}],
},
},
},
])
],

View File

@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page.component';
import { HomePageResolver } from './home-page.resolver';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -11,7 +13,21 @@ import { HomePageResolver } from './home-page.resolver';
path: '',
component: HomePageComponent,
pathMatch: 'full',
data: {title: 'home.title'},
data: {
title: 'home.title',
menu: {
public: [{
id: 'statistics_site',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics',
} as LinkMenuItemModel,
}],
},
},
resolve: {
site: HomePageResolver
}

View File

@@ -11,6 +11,8 @@ import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -43,6 +45,20 @@ import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
canActivate: [AuthenticatedGuard]
}
],
data: {
menu: {
public: [{
id: 'statistics_item_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/items/:id/',
} as LinkMenuItemModel,
}],
},
},
}
])
],

View File

@@ -69,6 +69,10 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{
path: 'statistics',
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
},
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]}
],

View File

@@ -171,6 +171,7 @@ import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
import { UsageReport } from './statistics/models/usage-report.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -371,7 +372,8 @@ export const models =
Vocabulary,
VocabularyEntry,
VocabularyEntryDetail,
ConfigurationProperty
ConfigurationProperty,
UsageReport,
];
@NgModule({

View File

@@ -0,0 +1,51 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { excludeFromEquals } from '../../utilities/equals.decorators';
import { ResourceType } from '../../shared/resource-type';
import { HALResource } from '../../shared/hal-resource.model';
import { USAGE_REPORT } from './usage-report.resource-type';
import { HALLink } from '../../shared/hal-link.model';
import { deserialize, autoserializeAs } from 'cerialize';
/**
* A usage report.
*/
@typedObject
@inheritSerialization(HALResource)
export class UsageReport extends HALResource {
static type = USAGE_REPORT;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
@autoserialize
id: string;
@autoserializeAs('report-type')
reportType: string;
@autoserialize
points: Point[];
@deserialize
_links: {
self: HALLink;
};
}
/**
* A statistics data point.
*/
export interface Point {
id: string;
label: string;
type: string;
values: Array<{
views: number;
}>;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for License
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const USAGE_REPORT = new ResourceType('usagereport');

View File

@@ -0,0 +1,62 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from '../data/data.service';
import { RequestService } from '../data/request.service';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { USAGE_REPORT } from './models/usage-report.resource-type';
import { UsageReport } from './models/usage-report.model';
import { Observable } from 'rxjs';
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators';
/**
* A service to retrieve {@link UsageReport}s from the REST API
*/
@Injectable()
@dataService(USAGE_REPORT)
export class UsageReportService extends DataService<UsageReport> {
protected linkPath = 'statistics/usagereports';
constructor(
protected comparator: DefaultChangeAnalyzer<UsageReport>,
protected halService: HALEndpointService,
protected http: HttpClient,
protected notificationsService: NotificationsService,
protected objectCache: ObjectCacheService,
protected rdbService: RemoteDataBuildService,
protected requestService: RequestService,
protected store: Store<CoreState>,
) {
super();
}
getStatistic(scope: string, type: string): Observable<UsageReport> {
return this.findById(`${scope}_${type}`).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
);
}
searchStatistics(uri: string, page: number, size: number): Observable<UsageReport[]> {
return this.searchBy('object', {
searchParams: [{
fieldName: `uri`,
fieldValue: uri,
}],
currentPage: page,
elementsPerPage: size,
}).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((list) => list.page),
);
}
}

View File

@@ -64,19 +64,6 @@ export class NavbarComponent extends MenuComponent {
link: `/community-list`
} as LinkMenuItemModel
},
/* Statistics */
{
id: 'statistics',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: ''
} as LinkMenuItemModel,
index: 2
},
];
// Read the different Browse-By types from config and add them to the browse menu
const types = environment.browseBy.types;

View File

@@ -14,6 +14,7 @@ import { MenuEffects } from './menu.effects';
describe('MenuEffects', () => {
let menuEffects: MenuEffects;
let routeDataMenuSection: MenuSection;
let routeDataMenuSectionResolved: MenuSection;
let routeDataMenuChildSection: MenuSection;
let toBeRemovedMenuSection: MenuSection;
let alreadyPresentMenuSection: MenuSection;
@@ -23,13 +24,23 @@ describe('MenuEffects', () => {
function init() {
routeDataMenuSection = {
id: 'mockSection',
id: 'mockSection_:idparam',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.mockSection',
link: ''
link: 'path/:linkparam'
} as LinkMenuItemModel
};
routeDataMenuSectionResolved = {
id: 'mockSection_id_param_resolved',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.mockSection',
link: 'path/link_param_resolved'
} as LinkMenuItemModel
};
routeDataMenuChildSection = {
@@ -70,6 +81,10 @@ describe('MenuEffects', () => {
menu: {
[MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection]
}
},
params: {
idparam: 'id_param_resolved',
linkparam: 'link_param_resolved',
}
},
firstChild: {
@@ -120,7 +135,7 @@ describe('MenuEffects', () => {
});
expect(menuEffects.buildRouteMenuSections$).toBeObservable(expected);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSection);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection);
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id);

View File

@@ -19,7 +19,7 @@ export class MenuEffects {
/**
* On route change, build menu sections for every menu type depending on the current route data
*/
@Effect({ dispatch: false })
@Effect({dispatch: false})
public buildRouteMenuSections$: Observable<Action> = this.actions$
.pipe(
ofType(ROUTER_NAVIGATED),
@@ -68,17 +68,52 @@ export class MenuEffects {
*/
resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] {
const data = route.snapshot.data;
const params = route.snapshot.params;
const last: boolean = hasNoValue(route.firstChild);
if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) {
let menuSections: MenuSection[] | MenuSection = data.menu[menuID];
menuSections = this.resolveSubstitutions(menuSections, params);
if (!last) {
return [...data.menu[menuID], ...this.resolveRouteMenuSections(route.firstChild, menuID)]
return [...menuSections, ...this.resolveRouteMenuSections(route.firstChild, menuID)]
} else {
return [...data.menu[menuID]];
return [...menuSections];
}
}
return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : [];
}
private resolveSubstitutions(object, params) {
let resolved;
if (typeof object === 'string') {
resolved = object;
let match: RegExpMatchArray;
do {
match = resolved.match(/:(\w+)/);
if (match) {
const substitute = params[match[1]];
if (hasValue(substitute)) {
resolved = resolved.replace(match[0], `${substitute}`);
}
}
} while (match);
} else if (Array.isArray(object)) {
resolved = [];
object.forEach((entry, index) => {
resolved[index] = this.resolveSubstitutions(object[index], params);
});
} else if (typeof object === 'object') {
resolved = {};
Object.keys(object).forEach((key) => {
resolved[key] = this.resolveSubstitutions(object[key], params);
});
} else {
resolved = object;
}
return resolved;
}
}

View File

@@ -0,0 +1,109 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CollectionStatisticsPageComponent } from './collection-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('CollectionStatisticsPageComponent', () => {
let component: CollectionStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<CollectionStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Collection(), {
id: 'collection_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
CollectionStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct collection', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('collection_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute , Router} from '@angular/router';
import { Collection } from '../../core/shared/collection.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for a collection.
*/
@Component({
selector: 'ds-collection-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./collection-statistics-page.component.scss']
})
export class CollectionStatisticsPageComponent extends StatisticsPageComponent<Collection> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,109 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityStatisticsPageComponent } from './community-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('CommunityStatisticsPageComponent', () => {
let component: CommunityStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<CommunityStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Community(), {
id: 'community_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
CommunityStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommunityStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct community', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('community_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Community } from '../../core/shared/community.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for a community.
*/
@Component({
selector: 'ds-community-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./community-statistics-page.component.scss']
})
export class CommunityStatisticsPageComponent extends StatisticsPageComponent<Community> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,111 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemStatisticsPageComponent } from './item-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('ItemStatisticsPageComponent', () => {
let component: ItemStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<ItemStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Item(), {
id: 'item_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
ItemStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct item', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('item_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TotalDownloads-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Item } from '../../core/shared/item.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for an item.
*/
@Component({
selector: 'ds-item-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./item-statistics-page.component.scss']
})
export class ItemStatisticsPageComponent extends StatisticsPageComponent<Item> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TotalDownloads',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,100 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SiteStatisticsPageComponent } from './site-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { Site } from '../../core/shared/site.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
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';
describe('SiteStatisticsPageComponent', () => {
let component: SiteStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<SiteStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
};
const router = {
};
const usageReportService = {
searchStatistics: () => observableOf([
Object.assign(
new UsageReport(), {
id: `site_id-TotalVisits-report`,
points: [],
}
),
]),
};
const nameService = {
getName: () => observableOf('test dso name'),
};
const siteService = {
find: () => observableOf(Object.assign(new Site(), {
id: 'site_id',
_links: {
self: {
href: 'test_site_link',
},
},
}))
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
SiteStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: SiteDataService, useValue: siteService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SiteStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct site', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('site_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.site_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,53 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { SiteDataService } from '../../core/data/site-data.service';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Site } from '../../core/shared/site.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { switchMap } from 'rxjs/operators';
/**
* Component representing the site-wide statistics page.
*/
@Component({
selector: 'ds-site-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./site-statistics-page.component.scss']
})
export class SiteStatisticsPageComponent extends StatisticsPageComponent<Site> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected siteService: SiteDataService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
protected getScope$() {
return this.siteService.find();
}
protected getReports$() {
return this.scope$.pipe(
switchMap((scope) =>
this.usageReportService.searchStatistics(scope._links.self.href, 0, 10),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { StatisticsPageModule } from './statistics-page.module';
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
import { ItemPageResolver } from '../+item-page/item-page.resolver';
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
import { CollectionPageResolver } from '../+collection-page/collection-page.resolver';
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
import { CommunityPageResolver } from '../+community-page/community-page.resolver';
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
@NgModule({
imports: [
StatisticsPageModule,
RouterModule.forChild([
{
path: '',
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
children: [
{
path: '',
component: SiteStatisticsPageComponent,
},
]
},
{
path: `items/:id`,
resolve: {
scope: ItemPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: ItemStatisticsPageComponent,
},
{
path: `collections/:id`,
resolve: {
scope: CollectionPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: CollectionStatisticsPageComponent,
},
{
path: `communities/:id`,
resolve: {
scope: CommunityPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: CommunityStatisticsPageComponent,
},
]
)
],
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
CollectionPageResolver,
CommunityPageResolver,
]
})
export class StatisticsPageRoutingModule {
}

View File

@@ -0,0 +1,39 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CoreModule } from '../core/core.module';
import { SharedModule } from '../shared/shared.module';
import { StatisticsModule } from '../statistics/statistics.module';
import { UsageReportService } from '../core/statistics/usage-report-data.service';
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
import { StatisticsTableComponent } from './statistics-table/statistics-table.component';
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
const components = [
StatisticsTableComponent,
SiteStatisticsPageComponent,
ItemStatisticsPageComponent,
CollectionStatisticsPageComponent,
CommunityStatisticsPageComponent,
];
@NgModule({
imports: [
CommonModule,
SharedModule,
CoreModule.forRoot(),
StatisticsModule.forRoot()
],
declarations: components,
providers: [
UsageReportService,
],
exports: components
})
/**
* This module handles all components and pipes that are necessary for the search page
*/
export class StatisticsPageModule {
}

View File

@@ -0,0 +1,29 @@
<div class="container">
<ng-container *ngVar="(scope$ | async) as scope">
<h2 *ngIf="scope"
class="header"
id="{{ scope.id }}">
{{ 'statistics.header' | translate: { scope: getName(scope) } }}
</h2>
</ng-container>
<ng-container *ngVar="reports$ | async as reports">
<ng-container *ngIf="!reports">
<ds-loading></ds-loading>
</ng-container>
<ng-container *ngIf="reports">
<ds-statistics-table *ngFor="let report of reports"
[report]="report"
class="m-2 {{ report.id }}">
</ds-statistics-table>
<div *ngIf="!(hasData$ | async)">
{{ 'statistics.page.no-data' | translate }}
</div>
</ng-container>
</ng-container>
</div>

View File

@@ -0,0 +1,84 @@
import { OnInit } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { map, switchMap } from 'rxjs/operators';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { RemoteData } from '../../core/data/remote-data';
import { getRemoteDataPayload, getSucceededRemoteData, redirectToPageNotFoundOn404 } from '../../core/shared/operators';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { ActivatedRoute, Router } from '@angular/router';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Class representing an abstract statistics page component.
*/
export abstract class StatisticsPageComponent<T extends DSpaceObject> implements OnInit {
/**
* The scope dso for this statistics page, as an Observable.
*/
scope$: Observable<DSpaceObject>;
/**
* The report types to show on this statistics page.
*/
types: string[];
/**
* The usage report types to show on this statistics page, as an Observable list.
*/
reports$: Observable<UsageReport[]>;
hasData$: Observable<boolean>;
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
}
ngOnInit(): void {
this.scope$ = this.getScope$();
this.reports$ = this.getReports$();
this.hasData$ = this.reports$.pipe(
map((reports) => reports.some(
(report) => report.points.length > 0
)),
);
}
/**
* Get the scope dso for this statistics page, as an Observable.
*/
protected getScope$(): Observable<DSpaceObject> {
return this.route.data.pipe(
map((data) => data.scope as RemoteData<T>),
redirectToPageNotFoundOn404(this.router),
getSucceededRemoteData(),
getRemoteDataPayload(),
);
}
/**
* Get the usage reports for this statistics page, as an Observable list
*/
protected getReports$(): Observable<UsageReport[]> {
return this.scope$.pipe(
switchMap((scope) =>
combineLatest(
this.types.map((type) => this.usageReportService.getStatistic(scope.id, type))
),
),
);
}
/**
* Get the name of the scope dso.
* @param scope the scope dso to get the name for
*/
getName(scope: DSpaceObject): string {
return this.nameService.getName(scope);
}
}

View File

@@ -0,0 +1,36 @@
<div *ngIf="hasData"
class="m-1">
<h3 class="m-1">
{{ 'statistics.table.title.' + report.reportType | translate }}
</h3>
<table class="table table-striped">
<tbody>
<tr>
<th scope="col"></th>
<th scope="col"
*ngFor="let header of headers"
class="{{header}}-header">
{{ header }}
</th>
</tr>
<tr *ngFor="let point of report.points"
class="{{point.id}}-data">
<th scope="row">
{{ getLabel(point) | async }}
</th>
<td *ngFor="let header of headers"
class="{{point.id}}-{{header}}-data">
{{ point.values[header] }}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,8 @@
th, td {
padding: 0.5rem;
}
td {
width: 50px;
max-width: 50px;
}

View File

@@ -0,0 +1,98 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { StatisticsTableComponent } from './statistics-table.component';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
describe('StatisticsTableComponent', () => {
let component: StatisticsTableComponent;
let de: DebugElement;
let fixture: ComponentFixture<StatisticsTableComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
],
declarations: [
StatisticsTableComponent,
],
providers: [
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: {} },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(StatisticsTableComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
component.report = Object.assign(new UsageReport(), {
points: [],
});
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when the storage report is empty', () => {
it ('should not display a table', () => {
expect(de.query(By.css('table'))).toBeNull();
});
});
describe('when the storage report has data', () => {
beforeEach(() => {
component.report = Object.assign(new UsageReport(), {
points: [
{
id: 'item_1',
values: {
views: 7,
downloads: 4,
},
},
{
id: 'item_2',
values: {
views: 8,
downloads: 8,
},
}
]
});
component.ngOnInit();
fixture.detectChanges();
});
it ('should display a table with the correct data', () => {
expect(de.query(By.css('table'))).toBeTruthy();
expect(de.query(By.css('th.views-header')).nativeElement.innerText)
.toEqual('views');
expect(de.query(By.css('th.downloads-header')).nativeElement.innerText)
.toEqual('downloads');
expect(de.query(By.css('td.item_1-views-data')).nativeElement.innerText)
.toEqual('7');
expect(de.query(By.css('td.item_1-downloads-data')).nativeElement.innerText)
.toEqual('4');
expect(de.query(By.css('td.item_2-views-data')).nativeElement.innerText)
.toEqual('8');
expect(de.query(By.css('td.item_2-downloads-data')).nativeElement.innerText)
.toEqual('8');
});
});
});

View File

@@ -0,0 +1,67 @@
import { Component, Input, OnInit } from '@angular/core';
import { Point, UsageReport } from '../../core/statistics/models/usage-report.model';
import { Observable, of } from 'rxjs';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { map } from 'rxjs/operators';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
/**
* Component representing a statistics table for a given usage report.
*/
@Component({
selector: 'ds-statistics-table',
templateUrl: './statistics-table.component.html',
styleUrls: ['./statistics-table.component.scss']
})
export class StatisticsTableComponent implements OnInit {
/**
* The usage report to display a statistics table for
*/
@Input()
report: UsageReport;
/**
* Boolean indicating whether the usage report has data
*/
hasData: boolean;
/**
* The table headers
*/
headers: string[];
constructor(
protected dsoService: DSpaceObjectDataService,
protected nameService: DSONameService,
) {
}
ngOnInit() {
this.hasData = this.report.points.length > 0;
if (this.hasData) {
this.headers = Object.keys(this.report.points[0].values);
}
}
/**
* Get the row label to display for a statistics point.
* @param point the statistics point to get the label for
*/
getLabel(point: Point): Observable<string> {
switch (this.report.reportType) {
case 'TotalVisits':
return this.dsoService.findById(point.id).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((item) => this.nameService.getName(item)),
);
case 'TopCities':
case 'topCountries':
default:
return of(point.label);
}
}
}

View File

@@ -2852,6 +2852,30 @@
"statistics.title": "Statistics",
"statistics.header": "Statistics for {{ scope }}",
"statistics.breadcrumbs": "Statistics",
"statistics.page.no-data": "No data available",
"statistics.table.no-data": "No data available",
"statistics.table.title.TotalVisits": "Total visits",
"statistics.table.title.TotalVisitsPerMonth": "Total visits per month",
"statistics.table.title.TotalDownloads": "File Visits",
"statistics.table.title.TopCountries": "Top country views",
"statistics.table.title.TopCities": "Top city views",
"statistics.table.header.views": "Views",
"submission.edit.title": "Edit Submission",
"submission.general.cannot_submit": "You have not the privilege to make a new submission.",