diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 7eec1c0ff9..233f15ccea 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -32,6 +32,7 @@ import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; +import {GoogleAnalyticsService} from './statistics/google-analytics.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -48,38 +49,40 @@ describe('App component', () => { }); } + const defaultTestBedConf = { + imports: [ + CommonModule, + StoreModule.forRoot(authReducer, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [AppComponent], // declare the test component + providers: [ + { provide: NativeWindowService, useValue: new NativeWindowRef() }, + { provide: MetadataService, useValue: new MetadataServiceMock() }, + { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() }, + { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: MenuService, useValue: menuService }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: LocaleService, useValue: getMockLocaleService() }, + provideMockStore({ initialState }), + AppComponent, + RouteService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }; + // waitForAsync beforeEach beforeEach(waitForAsync(() => { - return TestBed.configureTestingModule({ - imports: [ - CommonModule, - StoreModule.forRoot(authReducer, storeModuleConfig), - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - ], - declarations: [AppComponent], // declare the test component - providers: [ - { provide: NativeWindowService, useValue: new NativeWindowRef() }, - { provide: MetadataService, useValue: new MetadataServiceMock() }, - { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() }, - { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, - { provide: AuthService, useValue: new AuthServiceMock() }, - { provide: Router, useValue: new RouterMock() }, - { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, - { provide: MenuService, useValue: menuService }, - { provide: CSSVariableService, useClass: CSSVariableServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - { provide: LocaleService, useValue: getMockLocaleService() }, - provideMockStore({ initialState }), - AppComponent, - RouteService - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }); + return TestBed.configureTestingModule(defaultTestBedConf); })); // synchronous beforeEach @@ -113,4 +116,31 @@ describe('App component', () => { }); }); + + describe('when GoogleAnalyticsService is provided', () => { + let googleAnalyticsSpy; + + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(defaultTestBedConf); + googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [ + 'addTrackingIdToPage', + ]); + TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy}); + fixture = TestBed.createComponent(AppComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create component', () => { + expect(comp).toBeTruthy(); + }); + + describe('the constructor', () => { + it('should call googleAnalyticsService.addTrackingIdToPage()', () => { + expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 10cda90755..1ef5c868a6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -33,6 +33,7 @@ import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; import { hasValue } from './shared/empty.util'; import { KlaroService } from './shared/cookies/klaro.service'; +import {GoogleAnalyticsService} from './statistics/google-analytics.service'; @Component({ selector: 'ds-app', @@ -70,7 +71,8 @@ export class AppComponent implements OnInit, AfterViewInit { private menuService: MenuService, private windowService: HostWindowService, private localeService: LocaleService, - @Optional() private cookiesService: KlaroService + @Optional() private cookiesService: KlaroService, + @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { /* Use models object so all decorators are actually called */ @@ -84,7 +86,10 @@ export class AppComponent implements OnInit, AfterViewInit { // set the current language code this.localeService.setCurrentLanguageCode(); - angulartics2GoogleAnalytics.startTracking(); + // analytics + if (hasValue(googleAnalyticsService)) { + googleAnalyticsService.addTrackingIdToPage(); + } angulartics2DSpace.startTracking(); metadata.listenForRouteChange(); diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts new file mode 100644 index 0000000000..5a62b02334 --- /dev/null +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -0,0 +1,128 @@ +import { GoogleAnalyticsService } from './google-analytics.service'; +import {Angulartics2GoogleAnalytics} from 'angulartics2/ga'; +import {ConfigurationDataService} from '../core/data/configuration-data.service'; +import {createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../shared/remote-data.utils'; +import {ConfigurationProperty} from '../core/shared/configuration-property.model'; + +describe('GoogleAnalyticsService', () => { + const trackingIdProp = 'google.analytics.key'; + const trackingIdTestValue = 'mock-tracking-id'; + const innerHTMLTestValue = 'mock-script-inner-html'; + let service: GoogleAnalyticsService; + let angularticsSpy: Angulartics2GoogleAnalytics; + let configSpy: ConfigurationDataService; + let scriptElementMock: any; + let innerHTMLSpy: any; + let bodyElementSpy: HTMLBodyElement; + let documentSpy: Document; + + const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: trackingIdProp, + values: values, + }), + }); + + beforeEach(() => { + angularticsSpy = jasmine.createSpyObj('angulartics2GoogleAnalytics', [ + 'startTracking', + ]); + + configSpy = createConfigSuccessSpy(trackingIdTestValue); + + scriptElementMock = { + set innerHTML(newVal) { /* noop */ }, + get innerHTML() { return innerHTMLTestValue; } + }; + + innerHTMLSpy = spyOnProperty(scriptElementMock, 'innerHTML', 'set'); + + bodyElementSpy = jasmine.createSpyObj('body', { + appendChild: scriptElementMock, + }); + + documentSpy = jasmine.createSpyObj('document', { + createElement: scriptElementMock, + }, { + body: bodyElementSpy, + }); + + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('addTrackingIdToPage()', () => { + it(`should request the ${trackingIdProp} property`, () => { + service.addTrackingIdToPage(); + expect(configSpy.findByPropertyName).toHaveBeenCalledTimes(1); + expect(configSpy.findByPropertyName).toHaveBeenCalledWith(trackingIdProp); + }); + + describe('when the request fails', () => { + beforeEach(() => { + configSpy = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createFailedRemoteDataObject$(), + }); + + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + }); + + it('should NOT add a script to the body', () => { + service.addTrackingIdToPage(); + expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0); + }); + + it('should NOT start tracking', () => { + service.addTrackingIdToPage(); + expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the request succeeds', () => { + describe('when the tracking id is empty', () => { + beforeEach(() => { + configSpy = createConfigSuccessSpy(); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + }); + + it('should NOT add a script to the body', () => { + service.addTrackingIdToPage(); + expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0); + }); + + it('should NOT start tracking', () => { + service.addTrackingIdToPage(); + expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the tracking id is non-empty', () => { + it('should create a script tag whose innerHTML contains the tracking id', () => { + service.addTrackingIdToPage(); + expect(documentSpy.createElement).toHaveBeenCalledTimes(1); + expect(documentSpy.createElement).toHaveBeenCalledWith('script'); + + // sanity check + expect(documentSpy.createElement('script')).toBe(scriptElementMock); + + expect(innerHTMLSpy).toHaveBeenCalledTimes(1); + expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdTestValue); + }); + + it('should add a script to the body', () => { + service.addTrackingIdToPage(); + expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(1); + }); + + it('should start tracking', () => { + service.addTrackingIdToPage(); + expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts new file mode 100644 index 0000000000..ce4073bea5 --- /dev/null +++ b/src/app/statistics/google-analytics.service.ts @@ -0,0 +1,52 @@ +import {Inject, Injectable} from '@angular/core'; +import {Angulartics2GoogleAnalytics} from 'angulartics2/ga'; +import {ConfigurationDataService} from '../core/data/configuration-data.service'; +import {getFirstCompletedRemoteData} from '../core/shared/operators'; +import {isEmpty} from '../shared/empty.util'; +import {DOCUMENT} from '@angular/common'; + +/** + * Set up Google Analytics on the client side. + * See: {@link addTrackingIdToPage}. + */ +@Injectable() +export class GoogleAnalyticsService { + + constructor( + private angulartics: Angulartics2GoogleAnalytics, + private configService: ConfigurationDataService, + @Inject(DOCUMENT) private document: any, + ) { } + + /** + * Call this method once when Angular initializes on the client side. + * It requests a Google Analytics tracking id from the rest backend + * (property: google.analytics.key), adds the tracking snippet to the + * page and starts tracking. + */ + addTrackingIdToPage(): void { + this.configService.findByPropertyName('google.analytics.key').pipe( + getFirstCompletedRemoteData(), + ).subscribe((remoteData) => { + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { return; } + + const trackingId = remoteData.payload.values[0]; + + // make sure we received a tracking id + if (isEmpty(trackingId)) { return; } + + // add trackingId snippet to page + const keyScript = this.document.createElement('script'); + keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + ga('create', '${trackingId}', 'auto');`; + this.document.body.appendChild(keyScript); + + // start tracking + this.angulartics.startTracking(); + }); + } +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 0bc3d5eec4..5c6e56babb 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -23,7 +23,6 @@ export interface GlobalConfig extends Config { notifications: INotificationBoardOptions; submission: SubmissionConfig; universal: UniversalConfig; - gaTrackingId: string; logDirectory: string; debug: boolean; defaultLanguage: string; diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 1316b51f1a..4a87be38d3 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -132,8 +132,6 @@ export const environment: GlobalConfig = { async: true, time: false }, - // Google Analytics tracking id - gaTrackingId: '', // Log directory logDirectory: '.', // NOTE: will log all redux actions and transfers in console diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index b220c46083..ef3eb86cc2 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -110,8 +110,6 @@ export const environment: Partial = { async: true, time: false }, - // Google Analytics tracking id - gaTrackingId: '', // Log directory logDirectory: '.', // NOTE: will log all redux actions and transfers in console diff --git a/src/main.browser.ts b/src/main.browser.ts index 5149014d88..1f399b858a 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -6,7 +6,7 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { bootloader } from '@angularclass/bootloader'; import { load as loadWebFont } from 'webfontloader'; -import { hasValue, isNotEmpty } from './app/shared/empty.util'; +import { hasValue } from './app/shared/empty.util'; import { BrowserAppModule } from './modules/app/browser-app.module'; @@ -25,25 +25,9 @@ export function main() { } }); - addGoogleAnalytics(); - return platformBrowserDynamic().bootstrapModule(BrowserAppModule, {preserveWhitespaces:true}); } -function addGoogleAnalytics() { - // Add google analytics if key is present in config - const trackingId = environment.gaTrackingId; - if (isNotEmpty(trackingId)) { - const keyScript = document.createElement('script'); - keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');` - + 'ga(\'create\', \'' + environment.gaTrackingId + '\', \'auto\');'; - document.body.appendChild(keyScript); - } -} - // support async tag or hmr if (hasValue(environment.universal) && environment.universal.preboot === false) { bootloader(main); diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 3aa6bf244b..e0bd7b5ca1 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -30,6 +30,7 @@ import { LocationToken } from '../../app/core/services/browser-hard-redirect.service'; import { LocaleService } from '../../app/core/locale/locale.service'; +import {GoogleAnalyticsService} from '../../app/statistics/google-analytics.service'; export const REQ_KEY = makeStateKey('req'); @@ -99,6 +100,10 @@ export function getRequest(transferState: TransferState): any { provide: HardRedirectService, useClass: BrowserHardRedirectService, }, + { + provide: GoogleAnalyticsService, + useClass: GoogleAnalyticsService, + }, { provide: LocationToken, useFactory: locationProvider,