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 @@
+&nbsp;
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 @@
+&nbsp;
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