From dfa846a98e92ac3365025c8e0d5709c2f36768e0 Mon Sep 17 00:00:00 2001 From: Antoine Snyers <antoine@atmire.com> Date: Mon, 14 Oct 2019 09:53:22 +0200 Subject: [PATCH] Track page views and searches in DSpace with a custom Angulartics2 provider --- .../collection-page.component.html | 1 + .../collection-page.module.ts | 4 +- .../community-page.component.html | 1 + .../+community-page/community-page.module.ts | 6 +- .../+home-page/home-page-routing.module.ts | 17 +- src/app/+home-page/home-page.component.html | 3 + src/app/+home-page/home-page.component.ts | 21 +- src/app/+home-page/home-page.module.ts | 4 +- src/app/+home-page/home-page.resolver.ts | 25 +++ .../full/full-item-page.component.html | 1 + src/app/+item-page/item-page.module.ts | 4 +- .../simple/item-page.component.html | 1 + .../my-dspace-page.component.scss | 2 +- ...onfiguration-search-page.component.spec.ts | 2 +- .../configuration-search-page.component.ts | 8 +- .../filtered-search-page.component.spec.ts | 2 +- .../filtered-search-page.component.ts | 8 +- .../search-page-routing.module.ts | 3 +- .../+search-page/search-page.component.html | 52 +---- src/app/+search-page/search-page.component.ts | 180 +---------------- src/app/+search-page/search-page.module.ts | 10 +- .../search-configuration.service.ts | 4 +- .../search-service/search.service.ts | 60 ++++-- .../search-tracker.component.html | 1 + .../search-tracker.component.scss | 3 + .../+search-page/search-tracker.component.ts | 88 +++++++++ src/app/+search-page/search.component.html | 50 +++++ ...e.component.scss => search.component.scss} | 0 ...onent.spec.ts => search.component.spec.ts} | 12 +- src/app/+search-page/search.component.ts | 184 ++++++++++++++++++ src/app/app.component.spec.ts | 2 + src/app/app.component.ts | 4 + .../cache/models/normalized-site.model.ts | 13 ++ src/app/core/core.module.ts | 4 + .../data/search-response-parsing.service.ts | 4 + src/app/core/data/site-data.service.spec.ts | 104 ++++++++++ src/app/core/data/site-data.service.ts | 68 +++++++ src/app/core/shared/site.model.ts | 11 ++ .../shared/mocks/mock-angulartics.service.ts | 1 + src/app/shared/mocks/mock-request.service.ts | 3 +- .../angulartics/dspace-provider.spec.ts | 26 +++ .../statistics/angulartics/dspace-provider.ts | 38 ++++ .../dspace/view-tracker.component.html | 1 + .../dspace/view-tracker.component.scss | 3 + .../dspace/view-tracker.component.ts | 27 +++ src/app/statistics/statistics.module.ts | 36 ++++ src/app/statistics/statistics.service.spec.ts | 145 ++++++++++++++ src/app/statistics/statistics.service.ts | 93 +++++++++ src/app/statistics/track-request.model.ts | 4 + src/modules/app/browser-app.module.ts | 5 +- src/modules/app/server-app.module.ts | 7 + 51 files changed, 1080 insertions(+), 276 deletions(-) create mode 100644 src/app/+home-page/home-page.resolver.ts create mode 100644 src/app/+search-page/search-tracker.component.html create mode 100644 src/app/+search-page/search-tracker.component.scss create mode 100644 src/app/+search-page/search-tracker.component.ts create mode 100644 src/app/+search-page/search.component.html rename src/app/+search-page/{search-page.component.scss => search.component.scss} (100%) rename src/app/+search-page/{search-page.component.spec.ts => search.component.spec.ts} (95%) create mode 100644 src/app/+search-page/search.component.ts create mode 100644 src/app/core/cache/models/normalized-site.model.ts create mode 100644 src/app/core/data/site-data.service.spec.ts create mode 100644 src/app/core/data/site-data.service.ts create mode 100644 src/app/core/shared/site.model.ts create mode 100644 src/app/statistics/angulartics/dspace-provider.spec.ts create mode 100644 src/app/statistics/angulartics/dspace-provider.ts create mode 100644 src/app/statistics/angulartics/dspace/view-tracker.component.html create mode 100644 src/app/statistics/angulartics/dspace/view-tracker.component.scss create mode 100644 src/app/statistics/angulartics/dspace/view-tracker.component.ts create mode 100644 src/app/statistics/statistics.module.ts create mode 100644 src/app/statistics/statistics.service.spec.ts create mode 100644 src/app/statistics/statistics.service.ts create mode 100644 src/app/statistics/track-request.model.ts diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 436cd351a0..12d5c200fd 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -3,6 +3,7 @@ *ngVar="(collectionRD$ | async) as collectionRD"> <div *ngIf="collectionRD?.hasSucceeded" @fadeInOut> <div *ngIf="collectionRD?.payload as collection"> + <ds-view-tracker [object]="collection"></ds-view-tracker> <header class="comcol-header border-bottom mb-4 pb-4"> <!-- Collection logo --> <ds-comcol-page-logo *ngIf="logoRD$" diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index 0eaeca8ca7..e0a59e6916 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -12,12 +12,14 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c import { SearchService } from '../+search-page/search-service/search.service'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ CommonModule, SharedModule, - CollectionPageRoutingModule + CollectionPageRoutingModule, + StatisticsModule.forRoot() ], declarations: [ CollectionPageComponent, diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index 05d0bd1d0e..5bd7089e82 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -1,6 +1,7 @@ <div class="container" *ngVar="(communityRD$ | async) as communityRD"> <div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut> <div *ngIf="communityRD?.payload; let communityPayload"> + <ds-view-tracker [object]="communityPayload"></ds-view-tracker> <header class="comcol-header border-bottom mb-4 pb-4"> <!-- Community logo --> <ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'"> diff --git a/src/app/+community-page/community-page.module.ts b/src/app/+community-page/community-page.module.ts index 6d63cadcc8..8b02471fc2 100644 --- a/src/app/+community-page/community-page.module.ts +++ b/src/app/+community-page/community-page.module.ts @@ -6,17 +6,19 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityPageComponent } from './community-page.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageRoutingModule } from './community-page-routing.module'; -import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component'; +import { CommunityPageSubCommunityListComponent } from './sub-community-list/community-page-sub-community-list.component'; import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; import { CommunityFormComponent } from './community-form/community-form.component'; import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ CommonModule, SharedModule, - CommunityPageRoutingModule + CommunityPageRoutingModule, + StatisticsModule.forRoot() ], declarations: [ CommunityPageComponent, diff --git a/src/app/+home-page/home-page-routing.module.ts b/src/app/+home-page/home-page-routing.module.ts index d7dcc18f49..78da529906 100644 --- a/src/app/+home-page/home-page-routing.module.ts +++ b/src/app/+home-page/home-page-routing.module.ts @@ -2,12 +2,25 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { HomePageComponent } from './home-page.component'; +import { HomePageResolver } from './home-page.resolver'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: HomePageComponent, pathMatch: 'full', data: { title: 'home.title' } } + { + path: '', + component: HomePageComponent, + pathMatch: 'full', + data: {title: 'home.title'}, + resolve: { + site: HomePageResolver + } + } ]) + ], + providers: [ + HomePageResolver ] }) -export class HomePageRoutingModule { } +export class HomePageRoutingModule { +} diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 39ba479033..5515df595b 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,8 @@ <ds-home-news></ds-home-news> <div class="container"> + <ng-container *ngIf="(site$ | async) as site"> + <ds-view-tracker [object]="site"></ds-view-tracker> + </ng-container> <ds-search-form [inPlaceSearch]="false"></ds-search-form> <ds-top-level-community-list></ds-top-level-community-list> </div> diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts index 902a0e820d..1b915ae683 100644 --- a/src/app/+home-page/home-page.component.ts +++ b/src/app/+home-page/home-page.component.ts @@ -1,9 +1,26 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Site } from '../core/shared/site.model'; @Component({ selector: 'ds-home-page', styleUrls: ['./home-page.component.scss'], templateUrl: './home-page.component.html' }) -export class HomePageComponent { +export class HomePageComponent implements OnInit { + + site$:Observable<Site>; + + constructor( + private route:ActivatedRoute, + ) { + } + + ngOnInit():void { + this.site$ = this.route.data.pipe( + map((data) => data.site as Site), + ); + } } diff --git a/src/app/+home-page/home-page.module.ts b/src/app/+home-page/home-page.module.ts index c0c082b36c..51e978bbfe 100644 --- a/src/app/+home-page/home-page.module.ts +++ b/src/app/+home-page/home-page.module.ts @@ -6,12 +6,14 @@ import { HomePageRoutingModule } from './home-page-routing.module'; import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ CommonModule, SharedModule, - HomePageRoutingModule + HomePageRoutingModule, + StatisticsModule.forRoot() ], declarations: [ HomePageComponent, diff --git a/src/app/+home-page/home-page.resolver.ts b/src/app/+home-page/home-page.resolver.ts new file mode 100644 index 0000000000..1145d1d013 --- /dev/null +++ b/src/app/+home-page/home-page.resolver.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { SiteDataService } from '../core/data/site-data.service'; +import { Site } from '../core/shared/site.model'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + +/** + * The class that resolve the Site object for a route + */ +@Injectable() +export class HomePageResolver implements Resolve<Site> { + constructor(private siteService:SiteDataService) { + } + + /** + * Method for resolving a site object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<Site> Emits the found Site object, or an error if something went wrong + */ + resolve(route:ActivatedRouteSnapshot, state:RouterStateSnapshot):Observable<Site> | Promise<Site> | Site { + return this.siteService.find().pipe(take(1)); + } +} diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index 7aec57da0c..c453df6bff 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -1,6 +1,7 @@ <div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div *ngIf="itemRD?.payload as item"> + <ds-view-tracker [object]="item"></ds-view-tracker> <ds-item-page-title-field [item]="item"></ds-item-page-title-field> <div class="simple-view-link my-3"> <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]"> diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 2a5d0b6da7..28460f567a 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -26,6 +26,7 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ @@ -33,7 +34,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field SharedModule, ItemPageRoutingModule, EditItemPageModule, - SearchPageModule + SearchPageModule, + StatisticsModule.forRoot() ], declarations: [ ItemPageComponent, diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index 501e3e161e..b4b32fb05c 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,6 +1,7 @@ <div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div *ngIf="itemRD?.payload as item"> + <ds-view-tracker [object]="item"></ds-view-tracker> <ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader> </div> </div> diff --git a/src/app/+my-dspace-page/my-dspace-page.component.scss b/src/app/+my-dspace-page/my-dspace-page.component.scss index 86c589bf66..98c426c269 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.scss +++ b/src/app/+my-dspace-page/my-dspace-page.component.scss @@ -1 +1 @@ -@import '../+search-page/search-page.component.scss'; +@import '../+search-page/search.component.scss'; diff --git a/src/app/+search-page/configuration-search-page.component.spec.ts b/src/app/+search-page/configuration-search-page.component.spec.ts index a18dd38f78..1d0ed9c09c 100644 --- a/src/app/+search-page/configuration-search-page.component.spec.ts +++ b/src/app/+search-page/configuration-search-page.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { configureSearchComponentTestingModule } from './search-page.component.spec'; +import { configureSearchComponentTestingModule } from './search.component.spec'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index dbe54de72f..1296f3a203 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -1,7 +1,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { SearchService } from './search-service/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchPageComponent } from './search-page.component'; +import { SearchComponent } from './search.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; import { SearchConfigurationService } from './search-service/search-configuration.service'; @@ -16,8 +16,8 @@ import { RouteService } from '../core/services/route.service'; */ @Component({ selector: 'ds-configuration-search-page', - styleUrls: ['./search-page.component.scss'], - templateUrl: './search-page.component.html', + styleUrls: ['./search.component.scss'], + templateUrl: './search.component.html', changeDetection: ChangeDetectionStrategy.OnPush, animations: [pushInOut], providers: [ @@ -28,7 +28,7 @@ import { RouteService } from '../core/services/route.service'; ] }) -export class ConfigurationSearchPageComponent extends SearchPageComponent implements OnInit { +export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit { /** * The configuration to use for the search options * If empty, the configuration will be determined by the route parameter called 'configuration' diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts index 59ab9d7b0d..cf1668d8bc 100644 --- a/src/app/+search-page/filtered-search-page.component.spec.ts +++ b/src/app/+search-page/filtered-search-page.component.spec.ts @@ -1,6 +1,6 @@ import { FilteredSearchPageComponent } from './filtered-search-page.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { configureSearchComponentTestingModule } from './search-page.component.spec'; +import { configureSearchComponentTestingModule } from './search.component.spec'; import { SearchConfigurationService } from './search-service/search-configuration.service'; describe('FilteredSearchPageComponent', () => { diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts index 5da23bd853..19e5c09f71 100644 --- a/src/app/+search-page/filtered-search-page.component.ts +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -1,7 +1,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { SearchService } from './search-service/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchPageComponent } from './search-page.component'; +import { SearchComponent } from './search.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; import { SearchConfigurationService } from './search-service/search-configuration.service'; @@ -18,8 +18,8 @@ import { RouteService } from '../core/services/route.service'; */ @Component({ selector: 'ds-filtered-search-page', - styleUrls: ['./search-page.component.scss'], - templateUrl: './search-page.component.html', + styleUrls: ['./search.component.scss'], + templateUrl: './search.component.html', changeDetection: ChangeDetectionStrategy.OnPush, animations: [pushInOut], providers: [ @@ -30,7 +30,7 @@ import { RouteService } from '../core/services/route.service'; ] }) -export class FilteredSearchPageComponent extends SearchPageComponent implements OnInit { +export class FilteredSearchPageComponent extends SearchComponent implements OnInit { /** * The actual query for the fixed filter. * If empty, the query will be determined by the route parameter called 'filter' diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index d2c3b3be39..083a1b4410 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -1,9 +1,10 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { SearchPageComponent } from './search-page.component'; +import { SearchComponent } from './search.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; +import { SearchPageComponent } from './search-page.component'; @NgModule({ imports: [ diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 7efd22d5a9..5143d22809 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,50 +1,2 @@ -<div class="container" *ngIf="(isXsOrSm$ | async)"> - <div class="row"> - <div class="col-12"> - <ng-template *ngTemplateOutlet="searchForm"></ng-template> - </div> - </div> -</div> -<ds-page-with-sidebar [id]="'search-page'" [sidebarContent]="sidebarContent"> - <div class="row"> - <div class="col-12" *ngIf="!(isXsOrSm$ | async)"> - <ng-template *ngTemplateOutlet="searchForm"></ng-template> - </div> - <div id="search-content" class="col-12"> - <div class="d-block d-md-none search-controls clearfix"> - <ds-view-mode-switch [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch> - <button (click)="openSidebar()" aria-controls="#search-body" - class="btn btn-outline-primary float-right open-sidebar"><i - class="fas fa-sliders"></i> {{"search.sidebar.open" - | translate}} - </button> - </div> - <ds-search-results [searchResults]="resultsRD$ | async" - [searchConfig]="searchOptions$ | async" - [configuration]="configuration$ | async" - [disableHeader]="!searchEnabled"></ds-search-results> - </div> - </div> -</ds-page-with-sidebar> - -<ng-template #sidebarContent> - <ds-search-sidebar id="search-sidebar" *ngIf="!(isXsOrSm$ | async)" - [resultCount]="(resultsRD$ | async)?.payload?.totalElements" - [inPlaceSearch]="inPlaceSearch"></ds-search-sidebar> - <ds-search-sidebar id="search-sidebar-sm" *ngIf="(isXsOrSm$ | async)" - [resultCount]="(resultsRD$ | async)?.payload.totalElements" - (toggleSidebar)="closeSidebar()" - > - </ds-search-sidebar> -</ng-template> - -<ng-template #searchForm> - <ds-search-form *ngIf="searchEnabled" id="search-form" - [query]="(searchOptions$ | async)?.query" - [scope]="(searchOptions$ | async)?.scope" - [currentUrl]="searchLink" - [scopes]="(scopeListRD$ | async)" - [inPlaceSearch]="inPlaceSearch"> - </ds-search-form> - <ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels> -</ng-template> +<ds-search></ds-search> +<ds-search-tracker></ds-search-tracker> diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index bf0c2e3e73..6f980e287a 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,184 +1,8 @@ -import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; -import { startWith, switchMap, } from 'rxjs/operators'; -import { PaginatedList } from '../core/data/paginated-list'; -import { RemoteData } from '../core/data/remote-data'; -import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { pushInOut } from '../shared/animations/push'; -import { HostWindowService } from '../shared/host-window.service'; -import { PaginatedSearchOptions } from './paginated-search-options.model'; -import { SearchResult } from './search-result.model'; -import { SearchService } from './search-service/search.service'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; -import { getSucceededRemoteData } from '../core/shared/operators'; -import { RouteService } from '../core/services/route.service'; -import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; - -export const SEARCH_ROUTE = '/search'; - -/** - * This component renders a simple item page. - * The route parameter 'id' is used to request the item it represents. - * All fields of the item that should be displayed, are defined in its template. - */ +import { Component } from '@angular/core'; @Component({ selector: 'ds-search-page', - styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut], - providers: [ - { - provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] }) - -/** - * This component represents the whole search page - * It renders search results depending on the current search options - */ -export class SearchPageComponent implements OnInit { - /** - * The current search results - */ - resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null); - - /** - * The current paginated search options - */ - searchOptions$: Observable<PaginatedSearchOptions>; - - /** - * The current relevant scopes - */ - scopeListRD$: Observable<DSpaceObject[]>; - - /** - * Emits true if were on a small screen - */ - isXsOrSm$: Observable<boolean>; - - /** - * Subscription to unsubscribe from - */ - sub: Subscription; - - /** - * True when the search component should show results on the current page - */ - @Input() inPlaceSearch = true; - - /** - * Whether or not the search bar should be visible - */ - @Input() - searchEnabled = true; - - /** - * The width of the sidebar (bootstrap columns) - */ - @Input() - sideBarWidth = 3; - - /** - * The currently applied configuration (determines title of search) - */ - @Input() - configuration$: Observable<string>; - - /** - * Link to the search page - */ - searchLink: string; - - /** - * Observable for whether or not the sidebar is currently collapsed - */ - isSidebarCollapsed$: Observable<boolean>; - - constructor(protected service: SearchService, - protected sidebarService: SidebarService, - protected windowService: HostWindowService, - @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, - protected routeService: RouteService) { - this.isXsOrSm$ = this.windowService.isXsOrSm(); - } - - /** - * Listening to changes in the paginated search options - * If something changes, update the search results - * - * Listen to changes in the scope - * If something changes, update the list of scopes for the dropdown - */ - ngOnInit(): void { - this.isSidebarCollapsed$ = this.isSidebarCollapsed(); - this.searchLink = this.getSearchLink(); - this.searchOptions$ = this.getSearchOptions(); - this.sub = this.searchOptions$.pipe( - switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined)))) - .subscribe((results) => { - this.resultsRD$.next(results); - }); - this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( - switchMap((scopeId) => this.service.getScopes(scopeId)) - ); - if (!isNotEmpty(this.configuration$)) { - this.configuration$ = this.routeService.getRouteParameterValue('configuration'); - } - } - - /** - * Get the current paginated search options - * @returns {Observable<PaginatedSearchOptions>} - */ - protected getSearchOptions(): Observable<PaginatedSearchOptions> { - return this.searchConfigService.paginatedSearchOptions; - } - - /** - * Set the sidebar to a collapsed state - */ - public closeSidebar(): void { - this.sidebarService.collapse() - } - - /** - * Set the sidebar to an expanded state - */ - public openSidebar(): void { - this.sidebarService.expand(); - } - - /** - * Check if the sidebar is collapsed - * @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded - */ - private isSidebarCollapsed(): Observable<boolean> { - return this.sidebarService.isCollapsed; - } - - /** - * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true - */ - private getSearchLink(): string { - if (this.inPlaceSearch) { - return './'; - } - return this.service.getSearchLink(); - } - - /** - * Unsubscribe from the subscription - */ - ngOnDestroy(): void { - if (hasValue(this.sub)) { - this.sub.unsubscribe(); - } - } +export class SearchPageComponent { } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index b846771198..8ee81e549b 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { CoreModule } from '../core/core.module'; import { SharedModule } from '../shared/shared.module'; import { SearchPageRoutingModule } from './search-page-routing.module'; -import { SearchPageComponent } from './search-page.component'; +import { SearchComponent } from './search.component'; import { SearchResultsComponent } from './search-results/search-results.component'; import { CommunitySearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component' import { CollectionSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; @@ -33,7 +33,10 @@ import { SearchLabelComponent } from './search-labels/search-label/search-label. import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { FilteredSearchPageComponent } from './filtered-search-page.component'; +import { SearchPageComponent } from './search-page.component'; import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service'; +import { StatisticsModule } from '../statistics/statistics.module'; +import { SearchTrackerComponent } from './search-tracker.component'; const effects = [ SidebarEffects @@ -41,6 +44,7 @@ const effects = [ const components = [ SearchPageComponent, + SearchComponent, SearchResultsComponent, SearchSidebarComponent, SearchSettingsComponent, @@ -61,7 +65,8 @@ const components = [ SearchSwitchConfigurationComponent, SearchAuthorityFilterComponent, FilteredSearchPageComponent, - ConfigurationSearchPageComponent + ConfigurationSearchPageComponent, + SearchTrackerComponent, ]; @NgModule({ @@ -71,6 +76,7 @@ const components = [ SharedModule, EffectsModule.forFeature(effects), CoreModule.forRoot(), + StatisticsModule.forRoot(), ], declarations: components, providers: [ diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index 5a2343b058..4aed678645 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -226,7 +226,7 @@ export class SearchConfigurationService implements OnDestroy { this.getFiltersPart(), ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); - const updatedValue: SearchOptions = Object.assign(currentValue, update); + const updatedValue: SearchOptions = Object.assign(new SearchOptions({}), currentValue, update); this.searchOptions.next(updatedValue); }); } @@ -247,7 +247,7 @@ export class SearchConfigurationService implements OnDestroy { this.getFiltersPart(), ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); - const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); + const updatedValue: PaginatedSearchOptions = Object.assign(new PaginatedSearchOptions({}), currentValue, update); this.paginatedSearchOptions.next(updatedValue); }); } diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index d013d9cc64..c11e5b8be9 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,4 +1,4 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; import { first, map, switchMap, take, tap } from 'rxjs/operators'; @@ -23,7 +23,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -42,6 +42,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { RouteService } from '../../core/services/route.service'; +import { RequestEntry } from '../../core/data/request.reducer'; /** * Service that performs all general actions that have to do with the search page @@ -97,9 +98,9 @@ export class SearchService implements OnDestroy { } } - getEndpoint(searchOptions?: PaginatedSearchOptions): Observable<string> { + getEndpoint(searchOptions?:PaginatedSearchOptions):Observable<string> { return this.halService.getEndpoint(this.searchLinkPath).pipe( - map((url: string) => { + map((url:string) => { if (hasValue(searchOptions)) { return (searchOptions as PaginatedSearchOptions).toRestUrl(url); } else { @@ -116,32 +117,60 @@ export class SearchService implements OnDestroy { * @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found */ search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> { + return this.getPaginatedResults(this.searchEntries(searchOptions)); + } + + /** + * Method to retrieve request entries for search results from the server + * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search + * @param responseMsToLive The amount of milliseconds for the response to live in cache + * @returns {Observable<RequestEntry>} Emits an observable with the request entries + */ + searchEntries(searchOptions?: PaginatedSearchOptions, responseMsToLive?:number) + :Observable<{searchOptions:PaginatedSearchOptions, requestEntry:RequestEntry}> { const hrefObs = this.getEndpoint(searchOptions); const requestObs = hrefObs.pipe( - map((url: string) => { + map((url:string) => { const request = new this.request(this.requestService.generateRequestId(), url); - const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => { + const getResponseParserFn:() => GenericConstructor<ResponseParsingService> = () => { return this.parser; }; return Object.assign(request, { responseMsToLive: hasValue(responseMsToLive) ? responseMsToLive : request.responseMsToLive, - getResponseParser: getResponseParserFn + getResponseParser: getResponseParserFn, + searchOptions: searchOptions }); }), configureRequest(this.requestService), ); - const requestEntryObs = requestObs.pipe( - switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) + return requestObs.pipe( + switchMap((request:RestRequest) => this.requestService.getByHref(request.href)), + map(((requestEntry:RequestEntry) => ({ + searchOptions: searchOptions, + requestEntry: requestEntry + }))) + ); + } + + /** + * Method to convert the parsed responses into a paginated list of search results + * @param searchEntries: The request entries from the search method + * @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found + */ + getPaginatedResults(searchEntries:Observable<{ searchOptions:PaginatedSearchOptions, requestEntry:RequestEntry }>) + :Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> { + const requestEntryObs:Observable<RequestEntry> = searchEntries.pipe( + map((entry) => entry.requestEntry), ); // get search results from response cache - const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe( + const sqrObs:Observable<SearchQueryResponse> = requestEntryObs.pipe( filterSuccessfulResponses(), - map((response: SearchSuccessResponse) => response.results) + map((response:SearchSuccessResponse) => response.results), ); // turn dspace href from search results to effective list of DSpaceObjects @@ -187,11 +216,12 @@ export class SearchService implements OnDestroy { }) ); - return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe( - switchMap(([href, tDomainList, requestEntry]) => { + return observableCombineLatest(tDomainListObs, searchEntries).pipe( + switchMap(([tDomainList, searchEntry]) => { + const requestEntry = searchEntry.requestEntry; if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) { - this.requestService.removeByHrefSubstring(href); - return this.search(searchOptions) + this.requestService.removeByHrefSubstring(requestEntry.request.href); + return this.search(searchEntry.searchOptions) } else { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } diff --git a/src/app/+search-page/search-tracker.component.html b/src/app/+search-page/search-tracker.component.html new file mode 100644 index 0000000000..c0c0ffe181 --- /dev/null +++ b/src/app/+search-page/search-tracker.component.html @@ -0,0 +1 @@ + diff --git a/src/app/+search-page/search-tracker.component.scss b/src/app/+search-page/search-tracker.component.scss new file mode 100644 index 0000000000..c76cafbe44 --- /dev/null +++ b/src/app/+search-page/search-tracker.component.scss @@ -0,0 +1,3 @@ +:host { + display: none +} diff --git a/src/app/+search-page/search-tracker.component.ts b/src/app/+search-page/search-tracker.component.ts new file mode 100644 index 0000000000..e1df9b3905 --- /dev/null +++ b/src/app/+search-page/search-tracker.component.ts @@ -0,0 +1,88 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { Angulartics2 } from 'angulartics2'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { SearchComponent } from './search.component'; +import { SearchService } from './search-service/search.service'; +import { SidebarService } from '../shared/sidebar/sidebar.service'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { HostWindowService } from '../shared/host-window.service'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../core/services/route.service'; +import { hasValue } from '../shared/empty.util'; +import { SearchQueryResponse } from './search-service/search-query-response.model'; +import { SearchSuccessResponse } from '../core/cache/response.models'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; + +/** + * This component triggers a page view statistic + */ +@Component({ + selector: 'ds-search-tracker', + styleUrls: ['./search-tracker.component.scss'], + templateUrl: './search-tracker.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class SearchTrackerComponent extends SearchComponent implements OnInit { + + constructor( + protected service:SearchService, + protected sidebarService:SidebarService, + protected windowService:HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService:SearchConfigurationService, + protected routeService:RouteService, + public angulartics2:Angulartics2 + ) { + super(service, sidebarService, windowService, searchConfigService, routeService); + } + + ngOnInit():void { + // super.ngOnInit(); + this.getSearchOptions().pipe( + switchMap((options) => this.service.searchEntries(options) + .pipe( + filter((entry) => + hasValue(entry.requestEntry) + && hasValue(entry.requestEntry.response) + && entry.requestEntry.response.isSuccessful === true + ), + map((entry) => ({ + searchOptions: entry.searchOptions, + response: (entry.requestEntry.response as SearchSuccessResponse).results + })), + ) + ) + ) + .subscribe((entry) => { + const config:PaginatedSearchOptions = entry.searchOptions; + const searchQueryResponse:SearchQueryResponse = entry.response; + const filters:Array<{ filter:string, operator:string, value:string, label:string; }> = []; + const appliedFilters = searchQueryResponse.appliedFilters || []; + for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { + const appliedFilter = appliedFilters[i]; + filters.push(appliedFilter); + } + this.angulartics2.eventTrack.next({ + action: 'search', + properties: { + searchOptions: config, + page: { + size: config.pagination.size, // same as searchQueryResponse.page.elementsPerPage + totalElements: searchQueryResponse.page.totalElements, + totalPages: searchQueryResponse.page.totalPages, + number: config.pagination.currentPage, // same as searchQueryResponse.page.currentPage + }, + sort: { + by: config.sort.field, + order: config.sort.direction + }, + filters: filters, + }, + }) + }); + } +} diff --git a/src/app/+search-page/search.component.html b/src/app/+search-page/search.component.html new file mode 100644 index 0000000000..9423062bde --- /dev/null +++ b/src/app/+search-page/search.component.html @@ -0,0 +1,50 @@ +<div class="container" *ngIf="(isXsOrSm$ | async)"> + <div class="row"> + <div class="col-12"> + <ng-template *ngTemplateOutlet="searchForm"></ng-template> + </div> + </div> +</div> +<ds-page-with-sidebar [id]="'search-page'" [sidebarContent]="sidebarContent"> + <div class="row"> + <div class="col-12" *ngIf="!(isXsOrSm$ | async)"> + <ng-template *ngTemplateOutlet="searchForm"></ng-template> + </div> + <div id="search-content" class="col-12"> + <div class="d-block d-md-none search-controls clearfix"> + <ds-view-mode-switch [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch> + <button (click)="openSidebar()" aria-controls="#search-body" + class="btn btn-outline-primary float-right open-sidebar"><i + class="fas fa-sliders"></i> {{"search.sidebar.open" + | translate}} + </button> + </div> + <ds-search-results [searchResults]="resultsRD$ | async" + [searchConfig]="searchOptions$ | async" + [configuration]="configuration$ | async" + [disableHeader]="!searchEnabled"></ds-search-results> + </div> + </div> +</ds-page-with-sidebar> + +<ng-template #sidebarContent> + <ds-search-sidebar id="search-sidebar" *ngIf="!(isXsOrSm$ | async)" + [resultCount]="(resultsRD$ | async)?.payload?.totalElements" + [inPlaceSearch]="inPlaceSearch"></ds-search-sidebar> + <ds-search-sidebar id="search-sidebar-sm" *ngIf="(isXsOrSm$ | async)" + [resultCount]="(resultsRD$ | async)?.payload.totalElements" + (toggleSidebar)="closeSidebar()" + > + </ds-search-sidebar> +</ng-template> + +<ng-template #searchForm> + <ds-search-form *ngIf="searchEnabled" id="search-form" + [query]="(searchOptions$ | async)?.query" + [scope]="(searchOptions$ | async)?.scope" + [currentUrl]="searchLink" + [scopes]="(scopeListRD$ | async)" + [inPlaceSearch]="inPlaceSearch"> + </ds-search-form> + <ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels> +</ng-template> diff --git a/src/app/+search-page/search-page.component.scss b/src/app/+search-page/search.component.scss similarity index 100% rename from src/app/+search-page/search-page.component.scss rename to src/app/+search-page/search.component.scss diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search.component.spec.ts similarity index 95% rename from src/app/+search-page/search-page.component.spec.ts rename to src/app/+search-page/search.component.spec.ts index 9f17beeab3..ee5a80dec6 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search.component.spec.ts @@ -10,7 +10,7 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo import { CommunityDataService } from '../core/data/community-data.service'; import { HostWindowService } from '../shared/host-window.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SearchPageComponent } from './search-page.component'; +import { SearchComponent } from './search.component'; import { SearchService } from './search-service/search.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; @@ -27,11 +27,11 @@ import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; -let comp: SearchPageComponent; -let fixture: ComponentFixture<SearchPageComponent>; +let comp: SearchComponent; +let fixture: ComponentFixture<SearchComponent>; let searchServiceObject: SearchService; let searchConfigurationServiceObject: SearchConfigurationService; -const store: Store<SearchPageComponent> = jasmine.createSpyObj('store', { +const store: Store<SearchComponent> = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ @@ -152,11 +152,11 @@ export function configureSearchComponentTestingModule(compType) { describe('SearchPageComponent', () => { beforeEach(async(() => { - configureSearchComponentTestingModule(SearchPageComponent); + configureSearchComponentTestingModule(SearchComponent); })); beforeEach(() => { - fixture = TestBed.createComponent(SearchPageComponent); + fixture = TestBed.createComponent(SearchComponent); comp = fixture.componentInstance; // SearchPageComponent test instance fixture.detectChanges(); searchServiceObject = (comp as any).service; diff --git a/src/app/+search-page/search.component.ts b/src/app/+search-page/search.component.ts new file mode 100644 index 0000000000..705ecdff33 --- /dev/null +++ b/src/app/+search-page/search.component.ts @@ -0,0 +1,184 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { startWith, switchMap, } from 'rxjs/operators'; +import { PaginatedList } from '../core/data/paginated-list'; +import { RemoteData } from '../core/data/remote-data'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { pushInOut } from '../shared/animations/push'; +import { HostWindowService } from '../shared/host-window.service'; +import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SearchResult } from './search-result.model'; +import { SearchService } from './search-service/search.service'; +import { SidebarService } from '../shared/sidebar/sidebar.service'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { RouteService } from '../core/services/route.service'; +import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; + +export const SEARCH_ROUTE = '/search'; + +/** + * This component renders a simple item page. + * The route parameter 'id' is used to request the item it represents. + * All fields of the item that should be displayed, are defined in its template. + */ + +@Component({ + selector: 'ds-search', + styleUrls: ['./search.component.scss'], + templateUrl: './search.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [pushInOut], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * This component represents the whole search page + * It renders search results depending on the current search options + */ +export class SearchComponent implements OnInit { + /** + * The current search results + */ + resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null); + + /** + * The current paginated search options + */ + searchOptions$: Observable<PaginatedSearchOptions>; + + /** + * The current relevant scopes + */ + scopeListRD$: Observable<DSpaceObject[]>; + + /** + * Emits true if were on a small screen + */ + isXsOrSm$: Observable<boolean>; + + /** + * Subscription to unsubscribe from + */ + sub: Subscription; + + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch = true; + + /** + * Whether or not the search bar should be visible + */ + @Input() + searchEnabled = true; + + /** + * The width of the sidebar (bootstrap columns) + */ + @Input() + sideBarWidth = 3; + + /** + * The currently applied configuration (determines title of search) + */ + @Input() + configuration$: Observable<string>; + + /** + * Link to the search page + */ + searchLink: string; + + /** + * Observable for whether or not the sidebar is currently collapsed + */ + isSidebarCollapsed$: Observable<boolean>; + + constructor(protected service: SearchService, + protected sidebarService: SidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService) { + this.isXsOrSm$ = this.windowService.isXsOrSm(); + } + + /** + * Listening to changes in the paginated search options + * If something changes, update the search results + * + * Listen to changes in the scope + * If something changes, update the list of scopes for the dropdown + */ + ngOnInit(): void { + this.isSidebarCollapsed$ = this.isSidebarCollapsed(); + this.searchLink = this.getSearchLink(); + this.searchOptions$ = this.getSearchOptions(); + this.sub = this.searchOptions$.pipe( + switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined)))) + .subscribe((results) => { + this.resultsRD$.next(results); + }); + this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( + switchMap((scopeId) => this.service.getScopes(scopeId)) + ); + if (!isNotEmpty(this.configuration$)) { + this.configuration$ = this.routeService.getRouteParameterValue('configuration'); + } + } + + /** + * Get the current paginated search options + * @returns {Observable<PaginatedSearchOptions>} + */ + protected getSearchOptions(): Observable<PaginatedSearchOptions> { + return this.searchConfigService.paginatedSearchOptions; + } + + /** + * Set the sidebar to a collapsed state + */ + public closeSidebar(): void { + this.sidebarService.collapse() + } + + /** + * Set the sidebar to an expanded state + */ + public openSidebar(): void { + this.sidebarService.expand(); + } + + /** + * Check if the sidebar is collapsed + * @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded + */ + private isSidebarCollapsed(): Observable<boolean> { + return this.sidebarService.isCollapsed; + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + private getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.service.getSearchLink(); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 5b78e3462f..ad11daa121 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -46,6 +46,7 @@ import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockRouter } from './shared/mocks/mock-router'; import { MockCookieService } from './shared/mocks/mock-cookie.service'; import { CookieService } from './core/services/cookie.service'; +import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; let comp: AppComponent; let fixture: ComponentFixture<AppComponent>; @@ -74,6 +75,7 @@ describe('App component', () => { { provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, + { provide: Angulartics2DSpace, useValue: new AngularticsMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: Router, useValue: new MockRouter() }, { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 50baaf6e57..64e530fc84 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -34,6 +34,7 @@ import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; import { isNotEmpty } from './shared/empty.util'; import { CookieService } from './core/services/cookie.service'; +import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; export const LANG_COOKIE = 'language_cookie'; @@ -60,6 +61,7 @@ export class AppComponent implements OnInit, AfterViewInit { private store: Store<HostWindowState>, private metadata: MetadataService, private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, + private angulartics2DSpace: Angulartics2DSpace, private authService: AuthService, private router: Router, private cssService: CSSVariableService, @@ -89,6 +91,8 @@ export class AppComponent implements OnInit, AfterViewInit { } } + angulartics2DSpace.startTracking(); + metadata.listenForRouteChange(); if (config.debug) { diff --git a/src/app/core/cache/models/normalized-site.model.ts b/src/app/core/cache/models/normalized-site.model.ts new file mode 100644 index 0000000000..68a7e0a480 --- /dev/null +++ b/src/app/core/cache/models/normalized-site.model.ts @@ -0,0 +1,13 @@ +import { inheritSerialization } from 'cerialize'; +import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; +import { mapsTo } from '../builders/build-decorators'; +import { Site } from '../../shared/site.model'; + +/** + * Normalized model class for a Site object + */ +@mapsTo(Site) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedSite extends NormalizedDSpaceObject<Site> { + +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index a03cc6a9cb..f8a8626f15 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -121,6 +121,8 @@ import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model'; import { BrowseDefinition } from './shared/browse-definition.model'; import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service'; +import { SiteDataService } from './data/site-data.service'; +import { NormalizedSite } from './cache/models/normalized-site.model'; const IMPORTS = [ CommonModule, @@ -139,6 +141,7 @@ const PROVIDERS = [ AuthResponseParsingService, CommunityDataService, CollectionDataService, + SiteDataService, DSOResponseParsingService, DSpaceRESTv2Service, DynamicFormLayoutService, @@ -232,6 +235,7 @@ export const normalizedModels = NormalizedBitstream, NormalizedBitstreamFormat, NormalizedItem, + NormalizedSite, NormalizedCollection, NormalizedCommunity, NormalizedEPerson, diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 9ab0104393..389541745d 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -22,6 +22,10 @@ export class SearchResponseParsingService implements ResponseParsingService { } }; const payload = data.payload._embedded.searchResult || emptyPayload; + payload.appliedFilters = data.payload.appliedFilters; + payload.sort = data.payload.sort; + payload.scope = data.payload.scope; + payload.configuration = data.payload.configuration; const hitHighlights: MetadataMap[] = payload._embedded.objects .map((object) => object.hitHighlights) .map((hhObject) => { diff --git a/src/app/core/data/site-data.service.spec.ts b/src/app/core/data/site-data.service.spec.ts new file mode 100644 index 0000000000..189218b5cf --- /dev/null +++ b/src/app/core/data/site-data.service.spec.ts @@ -0,0 +1,104 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { SiteDataService } from './site-data.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Site } from '../shared/site.model'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../cache/response.models'; +import { RequestEntry } from './request.reducer'; +import { FindAllOptions } from './request.models'; +import { TestScheduler } from 'rxjs/testing'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; + +describe('SiteDataService', () => { + let scheduler:TestScheduler; + let service:SiteDataService; + let halService:HALEndpointService; + let requestService:RequestService; + let rdbService:RemoteDataBuildService; + let objectCache:ObjectCacheService; + + const testObject = Object.assign(new Site(), { + uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746', + }); + + const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; + const options = Object.assign(new FindAllOptions(), {}); + + const getRequestEntry$ = (successful:boolean, statusCode:number, statusText:string) => { + return observableOf({ + response: new RestResponse(successful, statusCode, statusText) + } as RequestEntry); + }; + + const siteLink = 'https://rest.api/rest/api/config/sites'; + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', {a: siteLink}) + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + getByHref: getRequestEntry$(true, 200, 'Success') + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildList: cold('a', { + a: new RemoteData(false, false, true, undefined, new PaginatedList(null, [testObject])) + }) + }); + + const store = {} as Store<CoreState>; + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const dataBuildService = {} as NormalizedObjectBuildService; + + service = new SiteDataService( + requestService, + rdbService, + dataBuildService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + }); + + describe('getBrowseEndpoint', () => { + it('should return the Static Page endpoint', () => { + + const result = service.getBrowseEndpoint(options); + const expected = cold('b', {b: siteLink}); + + expect(result).toBeObservable(expected); + }); + }); + + describe('find', () => { + it('should return the Site object', () => { + + spyOn(service, 'findAll').and.returnValue(cold('a', { + a: new RemoteData(false, false, true, undefined, new PaginatedList(null, [testObject])) + })); + + const expected = cold('(b|)', {b: testObject}); + const result = service.find(); + + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/data/site-data.service.ts b/src/app/core/data/site-data.service.ts new file mode 100644 index 0000000000..4993d47226 --- /dev/null +++ b/src/app/core/data/site-data.service.ts @@ -0,0 +1,68 @@ +import { DataService } from './data.service'; +import { Site } from '../shared/site.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindAllOptions } from './request.models'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; +import { Injectable } from '@angular/core'; +import { getSucceededRemoteData } from '../shared/operators'; + +/** + * Service responsible for handling requests related to the Site object + */ +@Injectable() +export class SiteDataService extends DataService<Site> { +​ + protected linkPath = 'sites'; + protected forceBypassCache = false; +​ + + constructor( + protected requestService:RequestService, + protected rdbService:RemoteDataBuildService, + protected dataBuildService:NormalizedObjectBuildService, + protected store:Store<CoreState>, + protected objectCache:ObjectCacheService, + protected halService:HALEndpointService, + protected notificationsService:NotificationsService, + protected http:HttpClient, + protected comparator:DSOChangeAnalyzer<Site>, + ) { + super(); + } + +​ + + /** + * Get the endpoint for browsing the site object + * @param {FindAllOptions} options + * @param {Observable<string>} linkPath + */ + getBrowseEndpoint(options:FindAllOptions, linkPath?:string):Observable<string> { + return this.halService.getEndpoint(this.linkPath); + } + +​ + + /** + * Retrieve the Site Object + */ + find():Observable<Site> { + return this.findAll().pipe( + getSucceededRemoteData(), + map((remoteData:RemoteData<PaginatedList<Site>>) => remoteData.payload), + map((list:PaginatedList<Site>) => list.page[0]) + ); + } +} diff --git a/src/app/core/shared/site.model.ts b/src/app/core/shared/site.model.ts new file mode 100644 index 0000000000..a191b2143f --- /dev/null +++ b/src/app/core/shared/site.model.ts @@ -0,0 +1,11 @@ +import { DSpaceObject } from './dspace-object.model'; +import { ResourceType } from './resource-type'; + +/** + * Model class for the Site object + */ +export class Site extends DSpaceObject { +​ + static type = new ResourceType('site'); +​ +} diff --git a/src/app/shared/mocks/mock-angulartics.service.ts b/src/app/shared/mocks/mock-angulartics.service.ts index 99a8b96b22..5581e183d1 100644 --- a/src/app/shared/mocks/mock-angulartics.service.ts +++ b/src/app/shared/mocks/mock-angulartics.service.ts @@ -1,4 +1,5 @@ /* tslint:disable:no-empty */ export class AngularticsMock { public eventTrack(action, properties) { } + public startTracking():void {} } diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index 9a320b749c..9d7f2baeab 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -1,8 +1,9 @@ import {of as observableOf, Observable } from 'rxjs'; import { RequestService } from '../../core/data/request.service'; import { RequestEntry } from '../../core/data/request.reducer'; +import SpyObj = jasmine.SpyObj; -export function getMockRequestService(requestEntry$: Observable<RequestEntry> = observableOf(new RequestEntry())): RequestService { +export function getMockRequestService(requestEntry$: Observable<RequestEntry> = observableOf(new RequestEntry())): SpyObj<RequestService> { return jasmine.createSpyObj('requestService', { configure: false, generateRequestId: 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', diff --git a/src/app/statistics/angulartics/dspace-provider.spec.ts b/src/app/statistics/angulartics/dspace-provider.spec.ts new file mode 100644 index 0000000000..d89d2d9fc6 --- /dev/null +++ b/src/app/statistics/angulartics/dspace-provider.spec.ts @@ -0,0 +1,26 @@ +import { Angulartics2DSpace } from './dspace-provider'; +import { Angulartics2 } from 'angulartics2'; +import { StatisticsService } from '../statistics.service'; +import { filter } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +describe('Angulartics2DSpace', () => { + let provider:Angulartics2DSpace; + let angulartics2:Angulartics2; + let statisticsService:jasmine.SpyObj<StatisticsService>; + + beforeEach(() => { + angulartics2 = { + eventTrack: observableOf({action: 'pageView', properties: {object: 'mock-object'}}), + filterDeveloperMode: () => filter(() => true) + } as any; + statisticsService = jasmine.createSpyObj('statisticsService', {trackViewEvent: null}); + provider = new Angulartics2DSpace(angulartics2, statisticsService); + }); + + it('should use the statisticsService', () => { + provider.startTracking(); + expect(statisticsService.trackViewEvent).toHaveBeenCalledWith('mock-object'); + }); + +}); diff --git a/src/app/statistics/angulartics/dspace-provider.ts b/src/app/statistics/angulartics/dspace-provider.ts new file mode 100644 index 0000000000..9ab01f6023 --- /dev/null +++ b/src/app/statistics/angulartics/dspace-provider.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { Angulartics2 } from 'angulartics2'; +import { StatisticsService } from '../statistics.service'; + +/** + * Angulartics2DSpace is a angulartics2 plugin that provides DSpace with the events. + */ +@Injectable({providedIn: 'root'}) +export class Angulartics2DSpace { + + constructor( + private angulartics2:Angulartics2, + private statisticsService:StatisticsService, + ) { + } + + /** + * Activates this plugin + */ + startTracking():void { + this.angulartics2.eventTrack + .pipe(this.angulartics2.filterDeveloperMode()) + .subscribe((event) => this.eventTrack(event)); + } + + private eventTrack(event) { + if (event.action === 'pageView') { + this.statisticsService.trackViewEvent(event.properties.object); + } else if (event.action === 'search') { + this.statisticsService.trackSearchEvent( + event.properties.searchOptions, + event.properties.page, + event.properties.sort, + event.properties.filters + ); + } + } +} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.html b/src/app/statistics/angulartics/dspace/view-tracker.component.html new file mode 100644 index 0000000000..c0c0ffe181 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.html @@ -0,0 +1 @@ + diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.scss b/src/app/statistics/angulartics/dspace/view-tracker.component.scss new file mode 100644 index 0000000000..c76cafbe44 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.scss @@ -0,0 +1,3 @@ +:host { + display: none +} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.ts b/src/app/statistics/angulartics/dspace/view-tracker.component.ts new file mode 100644 index 0000000000..1151287ea8 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.ts @@ -0,0 +1,27 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Angulartics2 } from 'angulartics2'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +/** + * This component triggers a page view statistic + */ +@Component({ + selector: 'ds-view-tracker', + styleUrls: ['./view-tracker.component.scss'], + templateUrl: './view-tracker.component.html', +}) +export class ViewTrackerComponent implements OnInit { + @Input() object:DSpaceObject; + + constructor( + public angulartics2:Angulartics2 + ) { + } + + ngOnInit():void { + this.angulartics2.eventTrack.next({ + action: 'pageView', + properties: {object: this.object}, + }); + } +} diff --git a/src/app/statistics/statistics.module.ts b/src/app/statistics/statistics.module.ts new file mode 100644 index 0000000000..a67ff7613c --- /dev/null +++ b/src/app/statistics/statistics.module.ts @@ -0,0 +1,36 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; +import { ViewTrackerComponent } from './angulartics/dspace/view-tracker.component'; +import { StatisticsService } from './statistics.service'; + +@NgModule({ + imports: [ + CommonModule, + CoreModule.forRoot(), + SharedModule, + ], + declarations: [ + ViewTrackerComponent, + ], + exports: [ + ViewTrackerComponent, + ], + providers: [ + StatisticsService + ] +}) +/** + * This module handles the statistics + */ +export class StatisticsModule { + static forRoot():ModuleWithProviders { + return { + ngModule: StatisticsModule, + providers: [ + StatisticsService + ] + }; + } +} diff --git a/src/app/statistics/statistics.service.spec.ts b/src/app/statistics/statistics.service.spec.ts new file mode 100644 index 0000000000..3a416968f8 --- /dev/null +++ b/src/app/statistics/statistics.service.spec.ts @@ -0,0 +1,145 @@ +import { StatisticsService } from './statistics.service'; +import { RequestService } from '../core/data/request.service'; +import { HALEndpointServiceStub } from '../shared/testing/hal-endpoint-service-stub'; +import { getMockRequestService } from '../shared/mocks/mock-request.service'; +import { TrackRequest } from './track-request.model'; +import { ResourceType } from '../core/shared/resource-type'; +import { SearchOptions } from '../+search-page/search-options.model'; +import { isEqual } from 'lodash'; +import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; + +describe('StatisticsService', () => { + let service:StatisticsService; + let requestService:jasmine.SpyObj<RequestService>; + const restURL = 'https://rest.api'; + const halService:any = new HALEndpointServiceStub(restURL); + + function initTestService() { + return new StatisticsService( + requestService, + halService, + ); + } + + describe('trackViewEvent', () => { + requestService = getMockRequestService(); + service = initTestService(); + + it('should send a request to track an item view ', () => { + const mockItem:any = {uuid: 'mock-item-uuid', type: 'item'}; + service.trackViewEvent(mockItem); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + expect(request.body).toBeDefined('request.body'); + const body = JSON.parse(request.body); + expect(body.targetId).toBe('mock-item-uuid'); + expect(body.targetType).toBe('item'); + }); + }); + + describe('trackSearchEvent', () => { + requestService = getMockRequestService(); + service = initTestService(); + + const mockSearch:any = new SearchOptions({ + query: 'mock-query', + }); + + const page = { + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }; + const sort = {by: 'search-field', order: 'ASC'}; + service.trackSearchEvent(mockSearch, page, sort); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + const body = JSON.parse(request.body); + + it('should specify the right query', () => { + expect(body.query).toBe('mock-query'); + }); + + it('should specify the pagination info', () => { + expect(body.page).toEqual({ + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }); + }); + + it('should specify the sort options', () => { + expect(body.sort).toEqual({ + by: 'search-field', + order: 'asc' + }); + }); + }); + + describe('trackSearchEvent with optional parameters', () => { + requestService = getMockRequestService(); + service = initTestService(); + + const mockSearch:any = new SearchOptions({ + query: 'mock-query', + configuration: 'mock-configuration', + dsoType: DSpaceObjectType.ITEM, + scope: 'mock-scope' + }); + + const page = { + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }; + const sort = {by: 'search-field', order: 'ASC'}; + const filters = [ + { + filter: 'title', + operator: 'notcontains', + value: 'dolor sit', + label: 'dolor sit' + }, + { + filter: 'author', + operator: 'authority', + value: '9zvxzdm4qru17or5a83wfgac', + label: 'Amet, Consectetur' + } + ]; + service.trackSearchEvent(mockSearch, page, sort, filters); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + const body = JSON.parse(request.body); + + it('should specify the dsoType', () => { + expect(body.dsoType).toBe('item'); + }); + + it('should specify the scope', () => { + expect(body.scope).toBe('mock-scope'); + }); + + it('should specify the configuration', () => { + expect(body.configuration).toBe('mock-configuration'); + }); + + it('should specify the filters', () => { + expect(isEqual(body.appliedFilters, [ + { + filter: 'title', + operator: 'notcontains', + value: 'dolor sit', + label: 'dolor sit' + }, + { + filter: 'author', + operator: 'authority', + value: '9zvxzdm4qru17or5a83wfgac', + label: 'Amet, Consectetur' + } + ])).toBe(true); + }); + }); + +}); diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts new file mode 100644 index 0000000000..6cfa6ebc29 --- /dev/null +++ b/src/app/statistics/statistics.service.ts @@ -0,0 +1,93 @@ +import { RequestService } from '../core/data/request.service'; +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { map, take } from 'rxjs/operators'; +import { TrackRequest } from './track-request.model'; +import { SearchOptions } from '../+search-page/search-options.model'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; +import { RestRequest } from '../core/data/request.models'; + +/** + * The statistics service + */ +@Injectable() +export class StatisticsService { + + constructor( + protected requestService:RequestService, + protected halService:HALEndpointService, + ) { + } + + private sendEvent(linkPath:string, body:any) { + const requestId = this.requestService.generateRequestId(); + this.halService.getEndpoint(linkPath).pipe( + map((endpoint:string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))), + take(1) // otherwise the previous events will fire again + ).subscribe((request:RestRequest) => this.requestService.configure(request, false)); + } + + /** + * To track a page view + * @param dso: The dso which was viewed + */ + trackViewEvent(dso:DSpaceObject) { + this.sendEvent('/statistics/viewevents', { + targetId: dso.uuid, + targetType: (dso as any).type + }); + } + + /** + * To track a search + * @param searchOptions: The query, scope, dsoType and configuration of the search. Filters from this object are ignored in favor of the filters parameter of this method. + * @param page: An object that describes the pagination status + * @param sort: An object that describes the sort status + * @param filters: An array of search filters used to filter the result set + */ + trackSearchEvent( + searchOptions:SearchOptions, + page:{ size:number, totalElements:number, totalPages:number, number:number }, + sort:{ by:string, order:string }, + filters?:Array<{ filter:string, operator:string, value:string, label:string }> + ) { + const body = { + query: searchOptions.query, + page: { + size: page.size, + totalElements: page.totalElements, + totalPages: page.totalPages, + number: page.number + }, + sort: { + by: sort.by, + order: sort.order.toLowerCase() + }, + }; + if (hasValue(searchOptions.configuration)) { + Object.assign(body, {configuration: searchOptions.configuration}) + } + if (hasValue(searchOptions.dsoType)) { + Object.assign(body, {dsoType: searchOptions.dsoType.toLowerCase()}) + } + if (hasValue(searchOptions.scope)) { + Object.assign(body, {scope: searchOptions.scope}) + } + if (isNotEmpty(filters)) { + const bodyFilters = []; + for (let i = 0, arrayLength = filters.length; i < arrayLength; i++) { + const filter = filters[i]; + bodyFilters.push({ + filter: filter.filter, + operator: filter.operator, + value: filter.value, + label: filter.label + }) + } + Object.assign(body, {appliedFilters: bodyFilters}) + } + this.sendEvent('/statistics/searchevents', body); + } + +} diff --git a/src/app/statistics/track-request.model.ts b/src/app/statistics/track-request.model.ts new file mode 100644 index 0000000000..df3e51c070 --- /dev/null +++ b/src/app/statistics/track-request.model.ts @@ -0,0 +1,4 @@ +import { PostRequest } from '../core/data/request.models'; + +export class TrackRequest extends PostRequest { +} diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index ede2b53e74..87b830ee7d 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,6 +21,8 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2Module } from 'angulartics2'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { SubmissionService } from '../../app/submission/submission.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { StatisticsModule } from '../../app/statistics/statistics.module'; export const REQ_KEY = makeStateKey<string>('req'); @@ -47,7 +49,8 @@ export function getRequest(transferState: TransferState): any { preloadingStrategy: IdlePreload }), - Angulartics2Module.forRoot([Angulartics2GoogleAnalytics]), + StatisticsModule.forRoot(), + Angulartics2Module.forRoot([Angulartics2GoogleAnalytics, Angulartics2DSpace]), BrowserAnimationsModule, DSpaceBrowserTransferStateModule, TranslateModule.forRoot({ diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 02abf6449b..44b21859bd 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -22,6 +22,8 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service'; import { SubmissionService } from '../../app/submission/submission.service'; import { ServerSubmissionService } from '../../app/submission/server-submission.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { Angulartics2Module } from 'angulartics2'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/assets/i18n/', '.json5'); @@ -45,6 +47,7 @@ export function createTranslateLoader() { deps: [] } }), + Angulartics2Module.forRoot([Angulartics2GoogleAnalytics, Angulartics2DSpace]), ServerModule, AppModule ], @@ -53,6 +56,10 @@ export function createTranslateLoader() { provide: Angulartics2GoogleAnalytics, useClass: AngularticsMock }, + { + provide: Angulartics2DSpace, + useClass: AngularticsMock + }, { provide: AuthService, useClass: ServerAuthService -- GitLab